Files
tiennm99 8223f40b16 feat(server): counters, readiness, drain mode and a version stamp
expvar counters for connections, rooms, games, submissions by rejection
reason, eliminations, chat, joins and bot moves, served on a separate
debug address so they never sit on the public mux, plus one structured
word_rejected log line per refused word carrying the normalized word and
its link. GET /readyz flips to 503 while draining; SIGTERM stops new
rooms, waits up to NOITU_DRAIN_TIMEOUT for live games, then shuts down.
GET /version and the startup log carry the build's git describe.

Fuzz targets for the frame decoder, the text sanitizer and Vietnamese
normalization; the last one found that composing before lowercasing
could leave a non-NFC result, now recomposed after lowering.

CI runs on dev as well as main, gates gofmt and golangci-lint, tracks the
buf major instead of an exact pin, and dependabot watches every
ecosystem. The lint findings that had been hidden by the default
per-issue cap are fixed.
2026-09-21 01:17:52 +07:00

63 lines
2.7 KiB
Go

// Package vietnamese normalizes Vietnamese word input.
//
// The same normalization runs in two places: the dictionary builder, which
// normalizes the corpus, and the game server, which normalizes what a player
// types. They must agree exactly — if they ever diverge, a word stored in the
// database stops matching the identical string typed by a player. Sharing this
// package is what prevents that.
package vietnamese
import (
"errors"
"strings"
"golang.org/x/text/unicode/norm"
)
// MinSyllables is the shortest word the game accepts. Nối từ is played with
// compound words; a single syllable is not a valid move.
const MinSyllables = 2
// ErrEmpty is returned when input contains no usable characters.
var ErrEmpty = errors.New("vietnamese: empty input")
// Normalize canonicalizes raw player or corpus input and splits it into syllables.
//
// It returns the canonical word (syllables joined by single spaces) and the
// syllables themselves. It deliberately does not enforce a syllable count:
// length is a game rule, not a text rule, so the engine owns that check.
//
// Steps, in order:
// - NFC composition, so "ữ" written as a base plus a combining mark compares
// equal to the single-codepoint form. Without this, words typed on different
// keyboards or platforms fail to match identical database entries.
// - lowercase, so capitalization never affects a lookup.
// - whitespace split and rejoin, which collapses runs of spaces, tabs, and
// non-breaking spaces that IMEs and copy-paste routinely introduce.
func Normalize(raw string) (string, []string, error) {
composed := norm.NFC.String(raw)
// Composed a second time after lowering: case-folding a base rune can
// enable a composition that only exists for its lowercase form — "Y" plus
// a combining ring above has no precomposed codepoint, but "y" plus the
// same ring does (U+1E99) — so lowering the already-composed string can
// hand back something that is no longer NFC. Fuzzing found this on
// synthetic input; Vietnamese text never hits it (the language has no such
// case-asymmetric diacritic), but the guarantee is meant to hold for
// whatever a player actually types.
lowered := norm.NFC.String(strings.ToLower(composed))
// strings.Fields splits on every unicode.IsSpace rune, which covers tabs and
// U+00A0 non-breaking spaces as well as ordinary spaces.
syllables := strings.Fields(lowered)
if len(syllables) == 0 {
return "", nil, ErrEmpty
}
return strings.Join(syllables, " "), syllables, nil
}
// HasEnoughSyllables reports whether a word is long enough to be a legal move.
func HasEnoughSyllables(syllables []string) bool {
return len(syllables) >= MinSyllables
}