diff --git a/.dockerignore b/.dockerignore index 3fc240e..b39ab54 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,9 @@ web/build web/.svelte-kit web/test-results web/playwright-report +data/*.bz2 +data/*.bz2.part +data/*.xml data/*.db data/*.db-journal data/*.db-wal diff --git a/server/cmd/build-dictionary/wikitext_test.go b/server/cmd/build-dictionary/wikitext_test.go index a597842..b17cad3 100644 --- a/server/cmd/build-dictionary/wikitext_test.go +++ b/server/cmd/build-dictionary/wikitext_test.go @@ -59,7 +59,7 @@ func TestStripWikitext(t *testing.T) { "Một nghĩa."}, {"a stray closer is not text", "Một }} nghĩa.", "Một nghĩa."}, {"only punctuation is empty", "(...).", ""}, - {"control characters and whitespace collapse", " Một từ \t hai ", "Một từ hai"}, + {"control characters and whitespace collapse", " Một\a từ \t hai ", "Một từ hai"}, {"unclosed template does not leak", "Một {{label|vi|x từ.", "Một"}, } for _, c := range cases { diff --git a/server/internal/bot/board_test.go b/server/internal/bot/board_test.go new file mode 100644 index 0000000..7a5bab1 --- /dev/null +++ b/server/internal/bot/board_test.go @@ -0,0 +1,36 @@ +package bot + +import ( + "iter" + + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// engineBoard adapts an Engine to Board for the simulations in this package. +// The server never needs it: the room hands the bot a Board of its own. +type engineBoard struct { + e *game.Engine + dict game.Dictionary +} + +// BoardFor wraps an engine so a test can drive a strategy against it. +// +// The dictionary comes from the engine rather than the caller: handing in a +// different one would let the bot search a graph the engine does not validate +// against, and that failure surfaces as the engine rejecting its own bot's +// move at runtime. +func BoardFor(e *game.Engine) Board { + return &engineBoard{e: e, dict: e.Dict()} +} + +func (b *engineBoard) LegalMoves() []string { return b.e.LegalMoves() } + +func (b *engineBoard) Used(word string) bool { return b.e.Used(word) } + +func (b *engineBoard) WordsStartingWith(syllable string) iter.Seq[string] { + return b.dict.WordsStartingWith(syllable) +} + +func (b *engineBoard) LastSyllable(word string) (string, bool) { + return b.dict.LastSyllable(word) +} diff --git a/server/internal/bot/bot.go b/server/internal/bot/bot.go index 0862d0a..c782870 100644 --- a/server/internal/bot/bot.go +++ b/server/internal/bot/bot.go @@ -14,8 +14,6 @@ import ( "iter" "math/rand/v2" "time" - - "github.com/tiennm99dev/noitu/server/internal/game" ) // Difficulty selects a strategy. @@ -96,34 +94,6 @@ func thinkingDelay(rng *rand.Rand, min, max time.Duration) time.Duration { return min + time.Duration(rng.Int64N(int64(max-min))) } -// engineBoard adapts an Engine to Board. -type engineBoard struct { - e *game.Engine - dict game.Dictionary -} - -// BoardFor wraps an engine so strategies can inspect it. -// -// The dictionary comes from the engine rather than the caller: handing in a -// different one would let the bot search a graph the engine does not validate -// against, and that failure surfaces as the engine rejecting its own bot's -// move at runtime. -func BoardFor(e *game.Engine) Board { - return &engineBoard{e: e, dict: e.Dict()} -} - -func (b *engineBoard) LegalMoves() []string { return b.e.LegalMoves() } - -func (b *engineBoard) Used(word string) bool { return b.e.Used(word) } - -func (b *engineBoard) WordsStartingWith(syllable string) iter.Seq[string] { - return b.dict.WordsStartingWith(syllable) -} - -func (b *engineBoard) LastSyllable(word string) (string, bool) { - return b.dict.LastSyllable(word) -} - // remainingOutDegree counts the continuations still available from a syllable, // ignoring `excluding` — the move being considered, which will itself be spent // once played. Pass "" to exclude nothing. diff --git a/server/internal/wsapi/convert.go b/server/internal/wsapi/convert.go index 643cf2f..bb46c0f 100644 --- a/server/internal/wsapi/convert.go +++ b/server/internal/wsapi/convert.go @@ -1,6 +1,6 @@ // Package wsapi is the transport layer: it translates between the game engine -// and the generated protobuf wire types, and (from phase 5 on) owns the -// WebSocket sessions and rooms built on top of them. +// and the generated protobuf wire types, and owns the WebSocket sessions and +// rooms built on top of them. // // The engine's reason enums and the wire enums are deliberately distinct // types. Renumbering an internal constant must never silently change what a diff --git a/server/internal/wsapi/hub.go b/server/internal/wsapi/hub.go index 3b0ae92..75d82ba 100644 --- a/server/internal/wsapi/hub.go +++ b/server/internal/wsapi/hub.go @@ -230,14 +230,6 @@ func (h *hub) shutdown() { } } -// roomCount reports live rooms. Tests use it to assert eviction; nothing in -// the server depends on it. -func (h *hub) roomCount() int { - h.mu.Lock() - defer h.mu.Unlock() - return len(h.rooms) -} - // playerIDFor is the seat a session holds. Declared here because the mapping // between a connection and a seat is registry knowledge, not game knowledge. func playerIDFor(seatName string) game.PlayerID { return game.PlayerID(seatName) } diff --git a/server/internal/wsapi/hub_test.go b/server/internal/wsapi/hub_test.go new file mode 100644 index 0000000..1cbaa69 --- /dev/null +++ b/server/internal/wsapi/hub_test.go @@ -0,0 +1,9 @@ +package wsapi + +// roomCount reports live rooms, so a test can assert that a room was evicted. +// Nothing in the server depends on it. +func (h *hub) roomCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.rooms) +} diff --git a/server/internal/wsapi/server.go b/server/internal/wsapi/server.go index 74f0c1e..b94f682 100644 --- a/server/internal/wsapi/server.go +++ b/server/internal/wsapi/server.go @@ -28,7 +28,7 @@ type Config struct { // that also serves the frontend. AllowedOrigins []string // WebDir is the built frontend. Empty, or missing on disk, serves the API - // alone — which is the state until phase 6 produces a bundle. + // alone, which is how the server runs before the frontend has been built. WebDir string } diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go index addfda8..1707eb6 100644 --- a/server/internal/wsapi/session.go +++ b/server/internal/wsapi/session.go @@ -276,8 +276,10 @@ func (s *session) run() { s.close() wg.Wait() + // readLoop only ever returns an error; a cancelled context is the one + // that means the server chose to close. status, reason := websocket.StatusNormalClosure, "" - if err != nil && !errors.Is(err, context.Canceled) { + if !errors.Is(err, context.Canceled) { status, reason = websocket.StatusPolicyViolation, "protocol error" } _ = s.conn.Close(status, reason) diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index 43a41ee..3b7b283 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -878,8 +878,8 @@ func TestSanitizeNickname(t *testing.T) { {"empty falls back", "", defaultNickname}, {"whitespace only falls back", " \t\n ", defaultNickname}, {"control characters stripped", "Mi\x00nh\x07", "Minh"}, - {"zero width stripped", "Mi​nh‍", "Minh"}, - {"bidi override stripped", "Minh‮", "Minh"}, + {"zero width stripped", "Mi\u200bnh\u200d", "Minh"}, + {"bidi override stripped", "Minh\u202e", "Minh"}, {"whitespace collapsed", " Minh Nguyen ", "Minh Nguyen"}, {"newlines become spaces", "Minh\nNguyen", "Minh Nguyen"}, {"over length truncated", strings.Repeat("a", 40), strings.Repeat("a", maxNicknameRunes)}, @@ -939,8 +939,10 @@ func TestBucketRefills(t *testing.T) { now := time.Now() b := newBucket(5, 2, now) - if !b.allow(now) || !b.allow(now) { - t.Fatal("burst should cover the first two") + for i := range 2 { + if !b.allow(now) { + t.Fatalf("burst should cover the first two, call %d refused", i+1) + } } if b.allow(now) { t.Fatal("third call should exhaust the bucket") diff --git a/web/tests/bundle.test.js b/web/tests/bundle.test.js index a43fce5..db84eb8 100644 --- a/web/tests/bundle.test.js +++ b/web/tests/bundle.test.js @@ -57,9 +57,9 @@ const DICTIONARY_WORDS = [ ]; /** - * A budget that binds. The bundle is around 190 KB, so this leaves room for the - * phase-7 screens without leaving room for a wordlist: the derived dictionary - * is about 3 MB, and even a fraction of it would not fit here. + * A budget that binds. The bundle is around 190 KB, so this leaves room for + * more screens without leaving room for a wordlist: the derived dictionary is + * several MB, and even a fraction of it would not fit here. */ const MAX_TOTAL_BYTES = 400_000;