mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 14:14:53 +00:00
A room holds up to four people and needs two to start. Both numbers are server constants sent to the client in RoomState, so the lobby draws whatever the server allows and widening a room is a server change alone. Failing a turn eliminates that player rather than ending the game. The syllable and the used words survive them, the turn passes to whoever is next, and the last player standing wins. Two seats is that same rule seen from close up, which is why there is one implementation of it and not two. A dead end still costs the first player to face it their own clock, as before: they get their turn, and lose it. Everyone behind them has already seen that board, so they go out together rather than each sitting out a turn limit they cannot use — which leaves the player who closed the position standing, the same outcome two players get. A player who is knocked out keeps their seat. They watch the rest of the game, chat included, with only the word input gone, and everybody lands back in the same lobby when it ends. The result screen is the whole table, ranked by who outlasted whom, with each score reported beside the place rather than deciding it. The turn clock is deliberately not paused for a seat that has dropped, so a player who loses their connection on their own turn loses it the way anybody else would. Their reconnect window decides only whether they are still in the game afterwards. Any number of windows can be open at once, settled by one timer armed for the nearest of them. Starting waits for every guest, not merely the first: a room of four that began on one yes would have dealt three people a turn they never agreed to. Kicking names a seat and is still refused on a player who is ready, and on the owner's own — leaving is what an owner who wants out does, and it hands the room on. Joining stays a lobby thing: a room with a game running turns a latecomer away even with seats going spare, because there is no way to hand somebody a game already in progress. BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that could only ever describe a second player, OpponentLeft is retired in favour of presence on RoomState, and suggestions move to the new PlayerEliminated — they describe the position that beat a player, which by the end of a longer game is nobody else's position. ProtocolVersion goes to 2, so a client built against 1 is refused with a readable error rather than decoding a frame that now means something else.
137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
// maxNicknameRunes caps the display name. Runes, not bytes: a Vietnamese name
|
|
// is multi-byte, and a byte cap would cut it far shorter than a Latin one.
|
|
const maxNicknameRunes = 20
|
|
|
|
// maxNicknameMarks is how many combining marks may follow one base rune. Two
|
|
// covers every Vietnamese cluster - a vowel can carry a diacritic and a tone
|
|
// mark and no more - so anything beyond it is stacking, not writing.
|
|
const maxNicknameMarks = 2
|
|
|
|
// denylisted is the hook for blocking names outright. It is deliberately a
|
|
// variable and deliberately empty: v1 has no abuse signal to tune a list
|
|
// against, and this way adding one later is a data change in one place rather
|
|
// than a new pass through the sanitizer.
|
|
var denylisted = func(string) bool { return false }
|
|
|
|
// sanitizeNickname turns untrusted input into something safe to show a
|
|
// stranger.
|
|
//
|
|
// This is an input-validation boundary, not cosmetics: the result is rendered
|
|
// in another player's browser, so anything that could forge UI or hide
|
|
// characters has to be gone before it is stored. The order matters —
|
|
// normalizing first means the length cap and the character filters see the
|
|
// same form the client will render, rather than a decomposed variant that
|
|
// counts differently.
|
|
//
|
|
// When nothing usable survives, the caller gets the generic name. Making that
|
|
// unique is the room's job, not this function's: two players can just as
|
|
// easily both *choose* "Minh", so the collision has to be resolved where both
|
|
// names are known.
|
|
func sanitizeNickname(raw string) string {
|
|
s := sanitizeText(raw, maxNicknameRunes, maxNicknameMarks)
|
|
if s == "" || denylisted(s) {
|
|
return defaultNickname
|
|
}
|
|
return s
|
|
}
|
|
|
|
// sanitizeText is the filter itself, without the nickname policy around it.
|
|
//
|
|
// Shared with chat, which needs the same guarantees at a different size: text
|
|
// that is safe to render in a stranger's browser, on one line, bounded. The
|
|
// caller decides the bounds and what an empty result means.
|
|
//
|
|
// Returns "" when nothing usable survives.
|
|
func sanitizeText(raw string, maxRunes, maxMarks int) string {
|
|
s := norm.NFC.String(raw)
|
|
|
|
// Drop anything non-printing. Format characters (Cf) are the important
|
|
// case: zero-width joiners and bidi overrides are invisible, so they can
|
|
// pad text past a visual check or reverse how it renders.
|
|
s = strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r == '\t' || r == '\n' || r == '\r':
|
|
return ' ' // collapsed below
|
|
case unicode.IsControl(r), unicode.Is(unicode.Cf, r):
|
|
return -1
|
|
case !unicode.IsPrint(r):
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
|
|
s = capMarks(s, maxMarks)
|
|
|
|
// Collapse runs of whitespace so text cannot be padded into a column of
|
|
// its own, then trim the edges.
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
|
|
if runes := []rune(s); len(runes) > maxRunes {
|
|
s = strings.TrimSpace(string(runes[:maxRunes]))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// capMarks limits how many combining marks may follow one base rune.
|
|
//
|
|
// The filter above cannot catch these: a combining mark is printable, is not a
|
|
// control or format character, and NFC leaves an uncomposable one where it is.
|
|
// A base rune followed by a hundred of them renders as a glyph cluster tall
|
|
// enough to cover the page it is displayed on, which is a layout attack rather
|
|
// than a word.
|
|
func capMarks(s string, maxMarks int) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
|
|
marks := 0
|
|
for _, r := range s {
|
|
if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) {
|
|
if marks >= maxMarks {
|
|
continue
|
|
}
|
|
marks++
|
|
} else {
|
|
marks = 0
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// defaultNickname is what an unusable name falls back to.
|
|
const defaultNickname = "Người chơi"
|
|
|
|
// distinguish returns a name the joining player cannot be confused with any of
|
|
// the ones already in the room. Nicknames are the only way to tell strangers
|
|
// apart, so letting two of them render the same string defeats the point of
|
|
// having names at all.
|
|
//
|
|
// The counter is bounded by the room: taken holds at most one name per seat, so
|
|
// a free suffix is always found within that many tries.
|
|
func distinguish(name string, taken []string) string {
|
|
if !slices.Contains(taken, name) {
|
|
return name
|
|
}
|
|
for n := 2; ; n++ {
|
|
suffix := fmt.Sprintf(" %d", n)
|
|
trimmed := name
|
|
if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes {
|
|
trimmed = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)]))
|
|
}
|
|
if candidate := trimmed + suffix; !slices.Contains(taken, candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|