From 582ba27354f509d531b3ab776b84ea845445ed13 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 4 Sep 2026 16:25:29 +0700 Subject: [PATCH] docs: add noi tu web game plan and dictionary research Research the Vietnamese noi tu word-chain game and plan a 7-phase web implementation: SvelteKit frontend, Go backend, WebSocket transport with Protobuf framing, and a server-authoritative dictionary over SQLite. The server validates every move so the browser never holds the wordlist, which keeps player-vs-player cheat-resistant and lets the bot and PvP modes share one rule implementation. Records the validated decisions: words of two or more syllables linking on first and last syllable, a 20s turn limit, user-typed nicknames, and Docker deployment behind a reverse proxy. --- .../phase-01-foundations-and-data-pipeline.md | 146 +++++++++ ...hase-02-go-dictionary-and-normalization.md | 108 +++++++ .../phase-03-go-game-engine-and-bot-ai.md | 144 +++++++++ .../phase-04-protobuf-contract-and-codegen.md | 170 +++++++++++ .../phase-05-go-websocket-server-and-rooms.md | 145 +++++++++ .../phase-06-sveltekit-frontend.md | 143 +++++++++ .../phase-07-online-1v1-and-release.md | 144 +++++++++ plans/260904-1125-noi-tu-web-game/plan.md | 281 ++++++++++++++++++ .../research-260904-1058-noi-tu-game.md | 201 +++++++++++++ 9 files changed, 1482 insertions(+) create mode 100644 plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md create mode 100644 plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md create mode 100644 plans/260904-1125-noi-tu-web-game/plan.md create mode 100644 plans/reports/research-260904-1058-noi-tu-game.md diff --git a/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md b/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md new file mode 100644 index 0000000..68e90da --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-01-foundations-and-data-pipeline.md @@ -0,0 +1,146 @@ +--- +title: "Phase 1: Foundations and Data Pipeline" +status: todo +phase: 1 +priority: P1 +effort: "3d" +dependencies: [] +--- + +# Phase 1: Foundations and Data Pipeline + +## Overview + +Stand up the repo skeleton and produce `data/noitu.db` — the derived, normalized Vietnamese +wordlist of words with **at least 2 syllables** — from the upstream `minhqnd/dictionary` +SQLite release, with full CC BY-SA 4.0 compliance. Nothing downstream can be built or +tested without this data. + +## Requirements + +**Functional** +- [x] Reproducible build: upstream `dictionary.db` → `data/noitu.db`, runnable by any contributor +- [x] Output contains only Vietnamese (`lang_code = 'vi'`) entries of **2 or more space-separated syllables** +- [x] All words NFC-normalized and lowercased +- [x] Tone-placement aliases resolved (`hoà`→`hòa`, `thuý`→`thúy`, `quí`→`quý`, …) +- [x] Precomputed `first`/`last` syllable columns and out-degree table for O(1) engine lookups +- [x] Build fails loudly if entry count falls below a floor (40,000) or any row has fewer than 2 syllables +- [x] `--max-syllables` flag available (default: no cap) so a phrase cap can be applied later without a code change + +**Non-functional** +- [x] Output DB is a few MB, opens read-only, no writes at runtime +- [x] License artifacts are correct and consistent across all five locations + +## Architecture + +**Source:** [`github.com/minhqnd/dictionary`](https://github.com/minhqnd/dictionary) release +**v2.0.0** → [`dictionary.db`](https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db), +**179 MB** (verified via the GitHub API; not in git; 357k+ entries, 1,500+ language pairs). +Code MIT, **data CC BY-SA 4.0**. + +We do **not** republish a derived dictionary — every build downloads this asset and derives +locally. See `plan.md` → Data Distribution for the CI and Docker consequences. + +**Derived schema** (`data/noitu.db`): + +```sql +CREATE TABLE words ( + word TEXT PRIMARY KEY, -- normalized, e.g. "pháp luật" + first TEXT NOT NULL, -- first syllable, "pháp" + last TEXT NOT NULL, -- last syllable, "luật" + syllables INTEGER NOT NULL -- 2, 3, 4, … +) WITHOUT ROWID; +CREATE INDEX idx_words_first ON words(first); + +-- Out-degree per syllable: how many words start with it. Drives bot heuristics +-- and instant dead-end detection. +CREATE TABLE syllables ( + syllable TEXT PRIMARY KEY, + out_degree INTEGER NOT NULL +) WITHOUT ROWID; + +-- Accepted spelling variants that map to a canonical word. Built offline so the +-- runtime never has to reason about Vietnamese tone placement. +CREATE TABLE aliases ( + variant TEXT PRIMARY KEY, + canonical TEXT NOT NULL REFERENCES words(word) +) WITHOUT ROWID; + +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); +-- rows: source_url, source_license, built_at, word_count, builder_version +``` + +**Why an alias table instead of a runtime normalizer:** Vietnamese old-style vs new-style +tone placement (`hoà` vs `hòa`) produces genuinely different codepoint sequences. A runtime +algorithm to re-place tone marks is fiddly and easy to get subtly wrong; generating both +variants once at build time is data, cheap to fix, and trivially testable. KISS. + +**Pipeline:** + +``` +dictionary.db ──► filter lang_code='vi' + ──► NFC normalize (golang.org/x/text/unicode/norm) + lowercase + collapse spaces + ──► keep len(strings.Fields(w)) >= 2 (and <= --max-syllables when set) + ──► drop entries containing digits, latin-only tokens, or punctuation + ──► dedupe + ──► generate tone-placement variants → aliases + ──► compute first/last/syllables + out_degree + ──► write noitu.db + meta + ──► assert: count >= 40000, every row >= 2 syllables, no orphan aliases +``` + +**Note on the graph:** allowing 3+ syllable words does not change the graph model — an edge +still runs `first ──word──► last`; it may simply span more syllables in between. Out-degree, +dead-end detection, and the engine's lookups are unaffected. + +## Related Code Files + +- Create: `server/cmd/build-dictionary/main.go` — CLI: `--in dictionary.db --out data/noitu.db [--max-syllables N]` +- Create: `server/cmd/build-dictionary/filter.go` — vi + ≥2-syllable + junk filters +- Create: `server/cmd/build-dictionary/aliases.go` — tone-placement variant generation +- Create: `server/cmd/build-dictionary/main_test.go` — filter/alias unit tests with fixtures +- Create: `data/LICENSE` — CC BY-SA 4.0 full text +- Create: `data/ATTRIBUTION.md` +- Create: `NOTICE` +- Create: `README.md` (replace stub) — quickstart, architecture, **License** section +- Create: `.gitignore` — `data/*.db` (covers both the 179 MB upstream and the derived DB), `web/node_modules`, `web/build`, server binaries +- Create: `Makefile` (or `Taskfile.yml`) — `make fetch-dict`, `make dict`, `make server`, `make web`, `make test` +- Create: `server/go.mod` — `module github.com/tiennm99dev/noitu/server`, Go 1.25+ (raised by modernc.org/sqlite; local toolchain is 1.26.5) +- Modify: none (repo is empty apart from `LICENSE` + `README.md`) + +## Implementation Steps + +1. `.gitignore`, `Makefile`, `server/go.mod` (`go mod init`), directory skeleton per plan layout. +2. Write `data/LICENSE` (verbatim CC BY-SA 4.0) and `data/ATTRIBUTION.md` naming: source repo + release URL, upstream sources (Wiktionary, `vntk/dictionary`), license URL, and the explicit modification list from `plan.md`. +3. Write `NOTICE` + README **License** section stating the split: Apache-2.0 code / CC BY-SA 4.0 data, and that the two are distributed as separate artifacts. +4. `server/cmd/build-dictionary`: open upstream DB read-only via `modernc.org/sqlite`, stream `vi` words. +5. Normalization: `norm.NFC`, `strings.ToLower`, `strings.Fields` → join with single space. +6. Filters: at least 2 fields (and ≤ `--max-syllables` when set); reject any token containing a digit, ASCII-only letters, or punctuation. +7. Alias generation: for each canonical word, emit old-style tone-placement variants for the `oa/oe/uy` nuclei; skip a variant if it collides with a different canonical word (log collisions). +8. Compute `first`, `last`, `syllables`, `out_degree`; write output DB in one transaction; write `meta` rows. +9. Assertions: word count ≥ 40,000; 100% of rows ≥ 2 syllables; every alias resolves; fail non-zero otherwise. +10. `make fetch-dict` downloads the 179 MB asset from the pinned release URL into `data/` (resumable, checksum-logged); `make dict` derives from it. Both documented in the README as one-time setup. +11. Unit tests on fixtures: normalization, ≥2-syllable filter (including a 3- and a 4-syllable entry accepted and a 1-syllable rejected), junk rejection, alias generation, collision handling. + +## Success Criteria + +- [x] `make fetch-dict && make dict` produces `data/noitu.db` from the upstream release +- [x] `sqlite3 data/noitu.db "SELECT COUNT(*) FROM words"` ≥ 40,000 +- [x] Zero rows where `word` has fewer than 2 syllables; 3- and 4-syllable entries present +- [x] `hoà`, `thuý`, `quí` each resolve through `aliases` to a canonical word present in `words` +- [x] `meta` records source URL, license, build timestamp, word count +- [x] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section all present and mutually consistent +- [x] `go test ./cmd/...` green +- [x] `data/*.db` is git-ignored + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| Upstream schema differs from README-documented shape | Query errors on first run | Inspect actual tables with `.schema` first; adapt the extraction query — the pipeline stages after extraction are schema-independent | +| Fewer than 40k clean ≥2-syllable entries | Count assertion fails | Union with [Viet74K](https://vietnamese-wordlist.duyet.net/Viet74K.txt) filtered to ≥2 syllables; add it as a second `--in` source and extend `ATTRIBUTION.md` | +| 179 MB download is slow or the release URL moves | `make fetch-dict` fails or stalls | URL is pinned to release `v2.0.0`, download is resumable, and the derived DB is cached locally so the fetch is a one-time cost per contributor | +| Allowing 3+ syllables admits multi-word phrases that are not really words | Playtest complaints | `--max-syllables` flag already present; applying a cap is a rebuild, not a code change | +| Upstream data contains proper nouns / non-words that make the game feel wrong | Playtest complaints in later phases | Add a denylist file consumed by the builder; regenerate — no code change needed | +| Alias generation creates false accepts (a variant that is a different real word) | Collision log non-empty | Collisions are skipped by design and logged; review the log before release | +| CC BY-SA obligations misread | License review | Attribution + share-alike on the data artifact only; code untouched. `NOTICE` states the boundary explicitly | diff --git a/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md b/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md new file mode 100644 index 0000000..ddb02ea --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-02-go-dictionary-and-normalization.md @@ -0,0 +1,108 @@ +--- +title: "Phase 2: Go Dictionary and Normalization" +status: todo +phase: 2 +priority: P1 +effort: "2d" +dependencies: [1] +--- + +# Phase 2: Go Dictionary and Normalization + +## Overview + +The read-side of the dictionary: a Go package that normalizes arbitrary player input the +same way the builder normalized the corpus, and a read-only SQLite store exposing the three +lookups the game engine needs. Everything above this layer treats words as opaque strings. + +## Requirements + +**Functional** +- [ ] `vietnamese.Normalize(raw) (word string, syllables []string, err error)` — NFC, lowercase, whitespace collapse, syllable split (any syllable count; length rules belong to the engine) +- [ ] Store resolves a player-typed variant to its canonical word via `aliases` +- [ ] `Store.Lookup(word)` → canonical word + exists +- [ ] `Store.WordsStartingWith(syllable)` → words (for bot move generation) +- [ ] `Store.OutDegree(syllable)` → int (0 means dead end) +- [ ] Store opens the DB **read-only**; concurrent-safe for many goroutines + +**Non-functional** +- [ ] Lookup ≤ 1ms p99 under 100 concurrent readers +- [ ] Normalization identical to the builder's — shared code path, not a reimplementation (DRY) + +## Architecture + +`internal/vietnamese` is imported by **both** `server/cmd/build-dictionary` and the server, so the +corpus and player input can never diverge. Phase 1 writes the builder against this package; +this phase hardens and tests it. + +```go +// internal/vietnamese +func Normalize(raw string) (string, []string, error) // NFC + lower + collapse + split +const MinSyllables = 2 +func HasEnoughSyllables(sylls []string) bool // len(sylls) >= MinSyllables + +// internal/dictionary +type Store struct{ db *sql.DB } +func Open(path string) (*Store, error) // file:...?mode=ro&_pragma=busy_timeout(5000) +func (s *Store) Resolve(word string) (canonical string, ok bool, err error) +func (s *Store) WordsStartingWith(syl string) ([]string, error) +func (s *Store) OutDegree(syl string) (int, error) +func (s *Store) RandomOpeningWord(minOutDegree int) (string, error) +func (s *Store) Close() error +``` + +**Resolve order:** exact hit in `words` → else `aliases` lookup → else not found. + +**The engine chains on the canonical word, never on the alias the player typed.** +An alias can differ from its canonical in the last syllable (`chức vỵ` vs `chức vị`), so +chaining on raw input would demand a next word linking from a syllable that is not in the +dictionary. `Resolve` therefore returns the canonical form, the engine records that, and the +client displays it — the player sees their word normalized to its dictionary spelling. +One prepared statement per query, held on the `Store`; `database/sql` handles pooling. + +**Hot-path caching:** `OutDegree` is read on every bot move. Load the whole `syllables` +table into a `map[string]int` at `Open` (a few tens of thousands of entries, ~1MB) and serve +from memory. `WordsStartingWith` stays on SQL — the result sets are small and per-turn. + +**Opening word selection:** pick uniformly from words whose `last` syllable has +`out_degree >= minOutDegree`, so a game never dies on move one. + +## Related Code Files + +- Create: `server/internal/vietnamese/normalize.go` +- Create: `server/internal/vietnamese/normalize_test.go` +- Create: `server/internal/dictionary/store.go` +- Create: `server/internal/dictionary/store_test.go` +- Create: `server/internal/dictionary/testdata/mini.db` — small fixture DB built by a test helper +- Modify: `server/go.mod` — add `modernc.org/sqlite`, `golang.org/x/text` +- Modify: `server/cmd/build-dictionary/main.go` — import `internal/vietnamese` instead of local normalization + +## Implementation Steps + +1. Implement `Normalize`: `norm.NFC.String` → `strings.ToLower` → `strings.Fields` → rejoin; return error on empty input. +2. Point the phase-1 builder at `internal/vietnamese` so one normalizer serves both sides. +3. `dictionary.Open`: DSN with `mode=ro`, verify `meta` table exists and log `word_count` + `source_license` at startup (license visibility). +4. Prepared statements for `Resolve` (words), `Resolve` (aliases), `WordsStartingWith`. +5. Load `syllables` into an in-memory map at open; `OutDegree` reads the map. +6. `RandomOpeningWord` via `ORDER BY RANDOM() LIMIT 1` over a joined out-degree filter. +7. Test helper that builds a tiny fixture DB in `t.TempDir()` from a hardcoded word set — no dependency on the real 60k DB in unit tests. +8. Tests: NFC equivalence (composed vs decomposed `ữ`), uppercase input, tabs/NBSP/multiple spaces, 1-syllable rejected while 3- and 4-syllable accepted, alias resolution for `hoà`/`thuý`/`quí`, unknown word, dead-end syllable returns 0. +9. Concurrency test: 100 goroutines × 1000 `Resolve` calls, `-race` clean. + +## Success Criteria + +- [ ] `go test ./internal/... -race` green +- [ ] Composed and decomposed spellings of the same word both resolve to one canonical entry +- [ ] Words of 2, 3, and 4 syllables all resolve; a 1-syllable input is rejected by `HasEnoughSyllables` +- [ ] `hoà lợi`-style tone variants resolve via `aliases` +- [ ] Store refuses to open a missing or writable-mode DB path with a clear error +- [ ] Startup log line names the data source and license +- [ ] Builder and server share one normalization implementation (no duplicate NFC/lowercase logic) + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| `modernc.org/sqlite` read performance disappoints under load | p99 lookup > 1ms in the concurrency test | Widen the in-memory cache: load `words` into a `map[string]string` too (~60k entries, a few MB) and use SQL only for cold paths | +| Normalization drift between builder and server | A word in the DB fails to match the same string typed by a player | Prevented structurally by the shared package; a regression test asserts round-trip on a sample of real DB rows | +| `strings.Fields` mishandles an exotic Unicode space | Rare rejection reports | `strings.Fields` already splits on all `unicode.IsSpace`; test explicitly covers NBSP and tab | diff --git a/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md new file mode 100644 index 0000000..91e1ac5 --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md @@ -0,0 +1,144 @@ +--- +title: "Phase 3: Go Game Engine and Bot AI" +status: todo +phase: 3 +priority: P1 +effort: "4d" +dependencies: [2] +--- + +# Phase 3: Go Game Engine and Bot AI + +## Overview + +Pure, transport-free game logic: rule validation, per-game state, win/loss resolution, and +the bot's move selection at three difficulties. No WebSocket, no protobuf, no timers driven +by wall clock — the engine takes a deadline as data so it stays fully unit-testable. + +## Requirements + +**Functional** +- [ ] `Engine.Submit(playerID, raw)` validates: at least 2 syllables → first syllable matches current → word exists → not already used +- [ ] Distinct rejection reasons, not a boolean +- [ ] Used-word set per game; a word is spent for both players +- [ ] Detects `no legal move remains` for the player to act +- [ ] Turn deadline held as `time.Time`, enforced by the caller; engine exposes `IsExpired(now)` +- [ ] Bot difficulties: Easy (random), Medium (prefer low out-degree), Hard (dead-end win + depth-limited search) +- [ ] Score model: points per accepted word, chain length tracked + +**Non-functional** +- [ ] Zero dependency on `wsapi` or generated protobuf types — engine speaks its own Go types +- [ ] Hard bot move decision ≤ 150ms +- [ ] Engine is safe under a single owning goroutine per game (documented; the room owns it) + +## Architecture + +The game is a **directed graph**: nodes = syllables, edges = words (`pháp ──"pháp luật"──► luật`). +A move traverses an unused edge from the current node. Formally this is *Directed Edge +Geography*, which is PSPACE-complete — so no perfect solver is attempted; the Hard bot uses +an instant-win check plus bounded search. Words of 3+ syllables are edges like any other — +they simply span extra syllables between `first` and `last`, so nothing in the graph model +changes. + +```go +type RejectReason int +const ( + ReasonNone RejectReason = iota + ReasonTooFewSyllables // fewer than vietnamese.MinSyllables (2) + ReasonWrongLink + ReasonNotInDictionary + ReasonAlreadyUsed + ReasonNotYourTurn + ReasonTimeout +) + +type Engine struct { + dict *dictionary.Store + used map[string]struct{} + current string // syllable the next word must start with + turn PlayerID + deadline time.Time + history []Move + scores map[PlayerID]int +} + +func New(dict *dictionary.Store, players []PlayerID, opening string, turnLimit time.Duration) (*Engine, error) +func (e *Engine) Submit(p PlayerID, raw string) (Move, RejectReason) +func (e *Engine) LegalMoves() ([]string, error) // for the player to act +func (e *Engine) HasLegalMove() (bool, error) +func (e *Engine) IsExpired(now time.Time) bool +func (e *Engine) Snapshot() State +``` + +**Validation order matters** — check cheapest and most-informative first so the player gets +the most useful message: turn → syllable count (≥2) → link → dictionary → reuse. + +**Scoring and word length:** longer words are worth more, so allowing 3+ syllables adds a +reason to reach for them rather than being merely permissive. See the scoring formula below. + +**Bot strategies** (`internal/bot`), all implementing one interface: + +```go +type Strategy interface{ Choose(e *game.Engine) (string, error) } +``` + +| Difficulty | Behaviour | +|---|---| +| **Easy** | Uniform random legal move. Adds a 400-900ms simulated "thinking" pause so it feels human | +| **Medium** | Score each legal move by the out-degree of the syllable it hands the opponent; pick randomly among the lowest quartile. Never plays a guaranteed kill | +| **Hard** | 1) if any legal move lands on a syllable with 0 remaining continuations → play it (instant win). 2) else negamax with alpha-beta, depth 4, over the remaining edge set; eval = `-log(1 + opponent legal move count)`. 3) else fall back to Medium | + +Search cost is bounded by move ordering (ascending opponent out-degree first) and a node +cap; the branching factor is the out-degree of visited syllables, typically < 50. + +**Anti-frustration:** Hard plays the instant-win move only `hardKillRate` of the time +(a tunable constant, default 0.85). A bot that always wins is not a game. + +**Scoring:** `10 + 2 × chainLength + 5 × (syllables − 2)` per accepted word, capped; final +score reported at game over. The syllable term rewards longer compounds now that they are +legal. Client persists a personal best in `localStorage` (phase 6) — no server storage. + +## Related Code Files + +- Create: `server/internal/game/engine.go` +- Create: `server/internal/game/state.go` — `Move`, `State`, `PlayerID`, `RejectReason` +- Create: `server/internal/game/engine_test.go` +- Create: `server/internal/bot/bot.go` — `Strategy` interface, difficulty registry, thinking delay +- Create: `server/internal/bot/strategy_easy.go` +- Create: `server/internal/bot/strategy_medium.go` +- Create: `server/internal/bot/strategy_hard.go` +- Create: `server/internal/bot/bot_test.go` +- Create: `server/internal/bot/simulate_test.go` — 100-game difficulty ladder assertion + +## Implementation Steps + +1. `state.go`: `PlayerID`, `Move{Player, Word, First, Last, Syllables, Points, At}`, `RejectReason` with `String()`, `State` snapshot struct. +2. `engine.New`: seed `used` with the opening word, set `current` to its last syllable, assign first turn. +3. `Submit`: normalize via `vietnamese.Normalize`, then run the validation order above; on success record the move, add to `used`, advance `current` and `turn`, reset deadline, add points. +4. `LegalMoves`: `dict.WordsStartingWith(current)` minus `used`. `HasLegalMove` short-circuits on the first hit rather than materializing the slice. +5. Engine unit tests, one per rejection reason, plus: reuse blocked across both players, chain advances correctly, no-legal-move detection, `IsExpired` boundary. +6. `bot.Strategy` interface + registry keyed by difficulty; shared randomized thinking delay helper. +7. Easy: uniform pick. Medium: out-degree-of-result scoring, lowest-quartile random pick, explicit guard that skips a 0-out-degree kill. +8. Hard: instant-win scan gated by `hardKillRate`; negamax + alpha-beta depth 4 with move ordering and node cap; Medium fallback. +9. `simulate_test.go`: 100 headless bot-vs-bot games per pairing; assert Hard beats Easy well above chance and Medium sits between. Deterministic via a seeded RNG. +10. Benchmark Hard's `Choose` to confirm ≤150ms on the real DB. + +## Success Criteria + +- [ ] `go test ./internal/game/... ./internal/bot/... -race` green +- [ ] Every `RejectReason` has a test that produces exactly it +- [ ] A 1-syllable submission yields `ReasonTooFewSyllables`; 3- and 4-syllable words are accepted and score the syllable bonus +- [ ] A word played by either player cannot be replayed by the other +- [ ] `HasLegalMove` returns false on a synthetic dead-end board and the engine reports the correct winner +- [ ] Hard win rate vs Easy > 70% over 100 seeded games; Medium strictly between Easy and Hard +- [ ] `BenchmarkHardChoose` ≤ 150ms/op against the real `noitu.db` +- [ ] Engine package imports no transport or protobuf package (verified by an import assertion test) + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| Depth-4 search too slow on hub syllables (high out-degree) | Benchmark exceeds 150ms | Node cap already present; reduce to depth 3 for syllables with out-degree > 200 — the depth is a tunable constant | +| Hard bot feels unbeatable | Playtest win rate ≈ 0% | Lower `hardKillRate`; it exists precisely for this dial | +| Bot picks obscure words that feel unfair | Playtest complaints | Out of scope for v1 (no frequency data in the derived DB); note as a post-v1 item requiring a frequency column | +| Engine mutated from two goroutines | `-race` failures once rooms land in phase 5 | Ownership rule documented here and enforced in phase 5: exactly one goroutine per room owns its engine | diff --git a/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md b/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md new file mode 100644 index 0000000..ef35d70 --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-04-protobuf-contract-and-codegen.md @@ -0,0 +1,170 @@ +--- +title: "Phase 4: Protobuf Contract and Codegen" +status: todo +phase: 4 +priority: P1 +effort: "2d" +dependencies: [1] +--- + +# Phase 4: Protobuf Contract and Codegen + +## Overview + +One `.proto` file defines every message crossing the WebSocket, and `buf` generates both the +Go server types and the JavaScript client types from it. No hand-written message structs on +either side — the schema is the single source of truth for the wire contract. + +Independent of phases 2-3; can be built in parallel with them. + +## Requirements + +**Functional** +- [ ] `proto/noitu/v1/game.proto` covers handshake with nickname, create/join room, start bot game, submit word, move results, turn state, game over, errors, heartbeat +- [ ] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto` +- [ ] Protocol version field in the handshake; server rejects mismatched majors with a readable error +- [ ] Generated code is committed (reviewable diffs, no toolchain needed to build) + +**Non-functional** +- [ ] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable +- [ ] `buf lint` and `buf breaking` (against `main`) run in CI + +## Architecture + +**Tooling:** [`buf`](https://buf.build) drives both targets from one config. +- Go: `protoc-gen-go` (messages only — no gRPC, this is raw WS framing) +- JS: [`@bufbuild/protobuf`](https://github.com/bufbuild/protobuf-es) **v2.14.1** + + `@bufbuild/protoc-gen-es` **v2.14.1** (both verified current on npm), generated with + `target=js` plus `.d.ts`. Chosen over `protobuf.js`: it is the only fully + conformance-compliant JS implementation, ships ESM for tree-shaking, and produces a much + smaller browser bundle. + +> `buf` is **not installed** in the current environment (`buf: command not found`). This does +> not block building or running: generated code is committed, so `buf` is required only when +> the schema changes. Install it before step 1. + +**Framing:** every WS frame is a binary message — `ClientMessage` client→server, +`ServerMessage` server→client, each a single `oneof`. No JSON fallback, no text frames. + +```proto +syntax = "proto3"; +package noitu.v1; +option go_package = "github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1"; + +enum Difficulty { DIFFICULTY_UNSPECIFIED = 0; EASY = 1; MEDIUM = 2; HARD = 3; } + +enum RejectReason { + REJECT_UNSPECIFIED = 0; + TOO_FEW_SYLLABLES = 1; // fewer than 2 syllables + WRONG_LINK = 2; + NOT_IN_DICTIONARY = 3; + ALREADY_USED = 4; + NOT_YOUR_TURN = 5; + TIMEOUT = 6; +} + +enum GameEndReason { + END_UNSPECIFIED = 0; + END_TIMEOUT = 1; + END_NO_LEGAL_MOVE = 2; + END_OPPONENT_LEFT = 3; + END_RESIGNED = 4; +} + +message Hello { uint32 protocol_version = 1; string resume_token = 2; string nickname = 3; } +message StartBotGame { Difficulty difficulty = 1; } +message CreateRoom {} +message JoinRoom { string room_code = 1; } +message SubmitWord { string word = 1; uint32 turn_seq = 2; } // turn_seq guards double-submits +message Resign {} +message Ping { int64 client_time_ms = 1; } + +message ClientMessage { + oneof payload { + Hello hello = 1; StartBotGame start_bot_game = 2; CreateRoom create_room = 3; + JoinRoom join_room = 4; SubmitWord submit_word = 5; Resign resign = 6; Ping ping = 7; + } +} + +// accepted_nickname is what the server actually stored after sanitization — the client +// must display this, not the string it sent. +message Welcome { string session_id = 1; string resume_token = 2; uint32 protocol_version = 3; string accepted_nickname = 4; } +message RoomCreated { string room_code = 1; } +message RoomJoined { string room_code = 1; string opponent_name = 2; } +message PlayedWord { string word = 1; bool by_me = 2; uint32 points = 3; uint32 syllables = 4; } +message GameStarted { + string opening_word = 1; string current_syllable = 2; bool my_turn = 3; + int64 deadline_unix_ms = 4; uint32 turn_seq = 5; uint32 turn_limit_ms = 6; +} +message TurnUpdate { + PlayedWord played = 1; string current_syllable = 2; bool my_turn = 3; + int64 deadline_unix_ms = 4; uint32 turn_seq = 5; + uint32 my_score = 6; uint32 opponent_score = 7; uint32 chain_length = 8; +} +message MoveRejected { RejectReason reason = 1; string word = 2; uint32 turn_seq = 3; } +message GameOver { bool i_won = 1; GameEndReason reason = 2; uint32 my_score = 3; uint32 chain_length = 4; } +message OpponentLeft { bool can_reconnect = 1; uint32 grace_ms = 2; } +message ServerError { string code = 1; string message = 2; } // message is a UI key, not prose +message Pong { int64 client_time_ms = 1; int64 server_time_ms = 2; } + +message ServerMessage { + oneof payload { + Welcome welcome = 1; RoomCreated room_created = 2; RoomJoined room_joined = 3; + GameStarted game_started = 4; TurnUpdate turn_update = 5; MoveRejected move_rejected = 6; + GameOver game_over = 7; OpponentLeft opponent_left = 8; ServerError error = 9; Pong pong = 10; + } +} +``` + +**Design notes** +- `TurnUpdate` is sent to **both** players after every accepted move, with `by_me`/`my_turn` + rendered per recipient — the server serializes one message per player, not a broadcast. +- `deadline_unix_ms` is an absolute server timestamp; the client renders a countdown from it. + `Ping`/`Pong` carry both clocks so the client can correct for offset. Never trust the client clock. +- `turn_seq` on `SubmitWord` makes double-submits and late submissions detectable server-side. +- `ServerError.message` carries a **UI key** (e.g. `room_not_found`), not user-facing prose — + the Vietnamese strings live in the frontend so all copy stays in one place. +- `Hello.nickname` is a **request**, not a fact. The server sanitizes it (phase 5) and returns + the stored value in `Welcome.accepted_nickname`; `RoomJoined.opponent_name` is always a + server-sanitized string. A client must never render another player's raw input. +- The engine's `RejectReason` and this proto enum are deliberately separate types with an + explicit mapping function; the wire contract must not be hostage to internal refactors. + +## Related Code Files + +- Create: `proto/noitu/v1/game.proto` +- Create: `buf.yaml`, `buf.gen.yaml`, `buf.lock` +- Create: `server/gen/noitu/v1/game.pb.go` (generated, committed) +- Create: `web/src/lib/proto/game_pb.js` (generated, committed) +- Create: `server/internal/wsapi/convert.go` — engine ↔ proto enum mapping + tests +- Modify: `Makefile` — `make proto` +- Modify: `server/go.mod` — `google.golang.org/protobuf` +- Modify: `web/package.json` — `@bufbuild/protobuf`, dev dep `@bufbuild/protoc-gen-es` + +## Implementation Steps + +1. `buf.yaml` (module + lint/breaking config) and `buf.gen.yaml` with the two plugins and their output dirs. +2. Write `game.proto` as above; `buf lint` clean. +3. `make proto` → `buf generate`; commit both generated trees. +4. `convert.go`: `game.RejectReason` → `noituv1.RejectReason`, engine end reasons → `GameEndReason`. Exhaustive `switch` with a default that returns the UNSPECIFIED value **and** logs — a silently-dropped new reason is a bug. +5. Round-trip test in Go: marshal each `ServerMessage` variant, unmarshal, assert equality. +6. Round-trip test in JS (Vitest): decode a fixture emitted by the Go test, assert field values — proves cross-language wire compatibility, not just self-consistency. +7. CI step: `buf lint`, `buf breaking --against '.git#branch=main'`, and a check that regenerating produces no diff (generated code stays in sync). + +## Success Criteria + +- [ ] `make proto` regenerates both targets with zero diff on a clean tree +- [ ] `buf lint` clean; `buf breaking` wired into CI +- [ ] Go round-trip test covers every `oneof` variant in both directions +- [ ] JS decodes a Go-produced binary fixture and reads correct values +- [ ] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart +- [ ] No hand-written message struct exists in `server/` or `web/` + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| Schema churn breaks an already-deployed client | Decode errors after a deploy | Additive-only changes, never reuse a tag, `reserved` on removals; `protocol_version` in `Hello` lets the server reject incompatible clients with a clear message instead of failing obscurely | +| `buf` unavailable in a contributor's environment | `make proto` fails locally | Generated code is committed, so building and running never requires `buf` — only changing the schema does | +| Protobuf runtime inflates the JS bundle | Bundle budget exceeded in phase 6 | `protobuf-es` is ESM/tree-shakeable and only the generated messages are imported; measure in phase 6 and drop to hand-rolled binary framing only if it genuinely fails the budget | +| Enum drift between engine and wire | A new reject reason silently arrives as UNSPECIFIED | Exhaustive-switch test plus a logged default in `convert.go` | diff --git a/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md b/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md new file mode 100644 index 0000000..6f30753 --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-05-go-websocket-server-and-rooms.md @@ -0,0 +1,145 @@ +--- +title: "Phase 5: Go WebSocket Server and Rooms" +status: todo +phase: 5 +priority: P1 +effort: "4d" +dependencies: [3, 4] +--- + +# Phase 5: Go WebSocket Server and Rooms + +## Overview + +The transport and orchestration layer: a WebSocket endpoint that speaks Protobuf frames, +a hub owning all live rooms, per-room goroutines driving the engine and turn timers, and +the bot wired in as a virtual player. After this phase the whole game is playable over +`websocat` with no frontend. + +## Requirements + +**Functional** +- [ ] `GET /ws` upgrades and runs the session loop; `GET /healthz` for liveness +- [ ] Bot rooms (1 human + bot) and PvP rooms (2 humans, joined by 6-char room code) +- [ ] Nicknames sanitized server-side and echoed back; opponents only ever see sanitized values +- [ ] Server-authoritative 20s turn timer (`NOITU_TURN_LIMIT`); expiry ends the game for the player on turn +- [ ] Every accepted move fans out a per-recipient `TurnUpdate` +- [ ] Bot moves scheduled on the room goroutine after a randomized thinking delay +- [ ] Session resume token issued in `Welcome`, honoured on reconnect within a grace window +- [ ] Serves the built frontend as static files so one binary is the whole deployment + +**Non-functional** +- [ ] One goroutine owns each room's engine — no shared mutable game state (`-race` clean) +- [ ] Per-connection read limit, read/write deadlines, and rate limiting on `SubmitWord` +- [ ] Graceful shutdown drains rooms and tells clients why + +## Architecture + +``` + ┌──────── hub (mutex-guarded maps) ────────┐ + conn ──► session ──chan──► room goroutine ──► game.Engine + │ │ └► bot.Strategy + │ └── time.Timer (turn deadline) + └── writer goroutine (owns the socket write side) +``` + +**Concurrency contract — the core invariant of this phase:** +- Each connection has exactly **one reader goroutine** and **one writer goroutine**. Nothing + else touches the socket. (`coder/websocket` tolerates concurrent writes, but a single + writer keeps ordering deterministic.) +- Each room has exactly **one goroutine** that owns its `game.Engine`. All input arrives as + messages on the room's channel. The engine is never locked because it is never shared. +- The hub owns only the `code → *room` and `sessionID → *session` maps, guarded by a mutex. + It never touches engine state. + +**Room lifecycle** + +``` +CreateRoom → generate code (6 chars, unambiguous alphabet: no 0/O/1/I) → room waits +JoinRoom → second player attaches → engine created → GameStarted to both +StartBotGame→ room created immediately with a bot as player 2 → GameStarted +in play → SubmitWord | timer fire | disconnect +GameOver → both notified → room lingers briefly for a rematch, then hub evicts it +``` + +**Turn timer:** the room goroutine holds a `time.Timer` for the current deadline, selected +on alongside the input channel. On fire: mark the player on turn as the loser +(`END_TIMEOUT`) and broadcast `GameOver`. The absolute deadline also goes to clients in +`TurnUpdate.deadline_unix_ms` — clients render a countdown but never decide the outcome. + +**Bot integration:** the bot is a player ID with no connection. When it is the bot's turn, +the room schedules `Choose` on a worker and delivers the result back through the same input +channel as a human move, so it flows through identical validation. If the bot has no legal +move, the human wins. + +**Reconnect:** `Welcome` carries a `resume_token`. On disconnect, the room keeps the seat +open for `graceMs` (default 30s) and sends the opponent `OpponentLeft{can_reconnect}`. A +`Hello` carrying a valid token rebinds the seat and replays current state via `GameStarted` ++ the latest `TurnUpdate`. Timeout ends the game as `END_OPPONENT_LEFT`. + +**Security / abuse limits** +- `conn.SetReadLimit(4096)` — no legitimate message approaches this (verified API: `SetReadLimit(n int64)`) +- Read deadlines come from `context.WithTimeout` passed to `conn.Read` — `coder/websocket` + has **no** `SetReadDeadline`; everything is context-driven. Ping via `conn.Ping(ctx)` every + 20s, treating a ctx timeout as a miss and closing after 2 +- `SubmitWord` rate-limited to ~5/sec per session (a submit is one dictionary lookup) +- Room codes from `crypto/rand`; join attempts rate-limited to make brute-forcing pointless +- Origin check via `websocket.AcceptOptions.OriginPatterns` from `NOITU_ALLOWED_ORIGINS`; + never set `InsecureSkipVerify` outside local dev +- **Nickname sanitization** (`sanitizeNickname`): NFC normalize, strip control characters and + zero-width joiners, collapse whitespace, trim, cap at 20 runes, reject empty → assign + `Người chơi N`. Nicknames are shown to strangers, so this is a real input-validation + boundary, not cosmetic. A denylist hook is left in place for post-v1 if abuse appears +- Every rejection returns a UI key, never a raw internal error string + +## Related Code Files + +- Create: `server/cmd/noitu-server/main.go` — flags/env, dict open, hub, HTTP mux, graceful shutdown +- Create: `server/internal/wsapi/server.go` — upgrade handler, origin check, static file serving +- Create: `server/internal/wsapi/session.go` — reader/writer goroutines, codec, resume tokens +- Create: `server/internal/wsapi/hub.go` — room + session registries, room code generation +- Create: `server/internal/wsapi/room.go` — room goroutine, timer, bot scheduling, fan-out +- Create: `server/internal/wsapi/codec.go` — protobuf marshal/unmarshal over binary frames +- Create: `server/internal/wsapi/ratelimit.go` +- Create: `server/internal/wsapi/nickname.go` — `sanitizeNickname` + tests +- Create: `server/internal/wsapi/room_test.go`, `session_test.go`, `hub_test.go` +- Modify: `server/go.mod` — `github.com/coder/websocket` +- Modify: `Makefile` — `make server`, `make run` + +## Implementation Steps + +1. `main.go`: config from env (`NOITU_ADDR`, `NOITU_DB_PATH`, `NOITU_TURN_LIMIT` default `20s`, `NOITU_ALLOWED_ORIGINS`, `NOITU_WEB_DIR`), open the dictionary read-only, construct hub, mount `/ws`, `/healthz`, and static assets, `signal.NotifyContext` shutdown. +2. `codec.go`: `Encode(*ServerMessage) []byte` / `Decode([]byte) (*ClientMessage, error)`; binary message type only, reject text frames. +3. `session.go`: reader goroutine (read limit, per-read `context.WithTimeout`, decode, forward to the room or hub) and writer goroutine (buffered channel, single owner of writes). Generate `session_id` and `resume_token` with `crypto/rand`; sanitize `Hello.nickname`; send `Welcome` carrying `accepted_nickname`; reject mismatched `protocol_version`. +4. `hub.go`: registries + mutex; `CreateBotRoom`, `CreateRoom`, `JoinRoom(code)`, `ResumeSession(token)`, eviction of finished/idle rooms via a janitor ticker. +5. `room.go`: the goroutine — `select` over input channel, turn timer, and context done. Handle `SubmitWord` (validate turn_seq, call engine, fan out per-recipient `TurnUpdate` or `MoveRejected`), `Resign`, disconnect, timer fire, bot scheduling, and no-legal-move detection after every move. +6. Per-recipient rendering: build `TurnUpdate` twice, flipping `by_me`/`my_turn` and swapping scores. Do not broadcast one shared message. +7. Bot wiring: on bot turn, run `Choose` on a worker goroutine with the room's context, deliver via the input channel after the thinking delay; cancel cleanly if the room ends first. +8. Reconnect: grace timer per seat, `OpponentLeft` to the other player, state replay on successful resume, `END_OPPONENT_LEFT` on grace expiry. +9. `ratelimit.go`: token bucket per session for `SubmitWord` and per IP for room joins. +10. Tests with in-process WS clients: full bot game to completion; two clients play a PvP game; timeout ends the game; reuse and wrong-link rejections reach the client with the right enum; disconnect+resume within grace restores state; resume after grace fails cleanly. All under `-race`. +11. Manual smoke via `websocat` documented in the README. + +## Success Criteria + +- [ ] `go test ./internal/wsapi/... -race` green +- [ ] A full bot game is playable end to end over a raw WS client, no frontend involved +- [ ] Two in-process clients complete a PvP game with correct alternating turns and a correct winner +- [ ] Turn timeout ends the game server-side even if the client sends nothing +- [ ] Disconnect + resume within 30s restores the game; after 30s the opponent wins by `END_OPPONENT_LEFT` +- [ ] Read limit, rate limit, and origin check each covered by a test +- [ ] `sanitizeNickname` tested against: over-length, control chars, zero-width chars, whitespace-only, and empty input; opponents never receive an unsanitized string +- [ ] `go build ./cmd/noitu-server` yields one binary that serves both `/ws` and the static frontend +- [ ] Graceful shutdown notifies connected clients rather than dropping sockets silently + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| Engine touched from two goroutines | `-race` failure | The one-goroutine-per-room rule is structural: the engine is only reachable through the room's channel. A review checklist item, not a runtime guard | +| Timer and message race (move accepted exactly at the deadline) | Flaky test, disputed loss | Resolve entirely inside the room goroutine: the timer fire and the move arrive on the same `select`, so one strictly precedes the other. `turn_seq` makes the outcome explicit to the client | +| Bot goroutine outlives its room | Goroutine leak under load test | Room context cancels the worker; a leak test asserts goroutine count returns to baseline after N games | +| Room code collision | Join lands in the wrong room | Generate with `crypto/rand` and retry on collision inside the hub mutex | +| Restart drops all live games | Players lose in-progress matches on deploy | Accepted for v1 (documented in `plan.md`); graceful shutdown sends a typed error so the UI can explain it | +| Offensive nicknames shown to strangers | Abuse reports | Sanitization caps length and strips control/zero-width characters; a denylist hook is already in `nickname.go` for a data-only fix if it becomes a real problem | +| Reconnect replay diverges from actual state | Resumed client shows a stale board | Replay is a fresh snapshot from the engine, never a stored copy of past messages | diff --git a/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md b/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md new file mode 100644 index 0000000..aef0c19 --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-06-sveltekit-frontend.md @@ -0,0 +1,143 @@ +--- +title: "Phase 6: SvelteKit Frontend" +status: todo +phase: 6 +priority: P1 +effort: "5d" +dependencies: [4, 5] +--- + +# Phase 6: SvelteKit Frontend + +## Overview + +The playable UI: a SvelteKit SPA in JavaScript that connects over WebSocket, speaks +Protobuf, and renders the game. Vietnamese throughout, dark mode, local high score. This +phase delivers the complete vs-bot experience; phase 7 layers the online 1v1 flows on top. + +## Requirements + +**Functional** +- [ ] Home screen: nickname input, play vs bot (Easy / Trung bình / Khó), or go to online play +- [ ] Nickname persisted in `localStorage`, sent in `Hello`, and replaced by `accepted_nickname` from `Welcome` +- [ ] Game screen: chain history, current syllable prompt, input, countdown ring, both scores +- [ ] Rejections shown as specific Vietnamese messages, mapped from `RejectReason` +- [ ] Game-over screen: result, final score, new-personal-best marker, rematch / home +- [ ] Dark mode toggle, persisted; respects `prefers-color-scheme` on first visit +- [ ] Personal best per difficulty in `localStorage` +- [ ] Connection status indicator; automatic reconnect with backoff +- [ ] In-app footer credit for the CC BY-SA 4.0 dictionary source, with links + +**Non-functional** +- [ ] `adapter-static` build, served by the Go binary +- [ ] Mobile-first; usable one-handed on a phone with the keyboard up +- [ ] No wordlist or dictionary data in the client bundle +- [ ] Vietnamese diacritic input (Telex/VNI IMEs) works — no input interception that breaks composition + +## Architecture + +**Stack:** SvelteKit 2 + Svelte 5 (runes: `$state`, `$derived`, `$effect`), plain JavaScript +with JSDoc types, `adapter-static`, Vite. Scaffold with `npx sv create`. + +``` +web/src/ + lib/ + proto/ # generated (phase 4) — do not hand-edit + ws/client.js # socket lifecycle, protobuf codec, reconnect backoff, ping/pong + ws/messages.js # thin senders: startBotGame(), submitWord(), createRoom(), joinRoom() + stores/game.svelte.js # $state game model, fed only by ServerMessage + stores/settings.svelte.js# nickname + theme + high scores, localStorage-backed + i18n/vi.js # ALL user-facing strings, incl. RejectReason → message map + components/ # ChainHistory, WordInput, CountdownRing, ScoreBoard, DifficultyPicker, + # ConnectionBadge, GameOverPanel, ThemeToggle, AttributionFooter + routes/ + +layout.svelte # theme application, footer + +page.svelte # home + play/+page.svelte # game screen (bot and PvP share it) + online/+page.svelte # room create/join (phase 7 fills this in) +``` + +**State rule:** the store is a projection of server messages. The client never decides +whether a word is valid, whose turn it is, or who won — it renders what the server sent. +The only client-owned state is theme, personal best, and the input box. + +**Countdown:** derived from `deadline_unix_ms` minus a clock offset measured by `Ping`/`Pong`, +re-evaluated on `requestAnimationFrame`. Purely cosmetic — the server decides expiry. + +**Reconnect:** exponential backoff (0.5s → 8s, jittered). On reconnect send `Hello` with the +stored `resume_token`; the server either restores the game or returns a typed error and the +UI falls back to the home screen with an explanation. + +**Vietnamese input:** bind on `change`/submit and read `event.target.value`; never +re-write the input's value mid-composition, and listen for `compositionstart`/`compositionend` +before any normalization — rewriting the field while a Telex IME is composing corrupts +diacritic entry. Normalization is the server's job anyway. + +**i18n:** every string lives in `lib/i18n/vi.js`, including the `RejectReason` map: + +| Reason | Message | +|---|---| +| `TOO_FEW_SYLLABLES` | "Từ phải có ít nhất 2 tiếng." | +| `WRONG_LINK` | "Từ phải bắt đầu bằng tiếng \"{syllable}\"." | +| `NOT_IN_DICTIONARY` | "Không tìm thấy từ này trong từ điển." | +| `ALREADY_USED` | "Từ này đã được dùng rồi." | +| `NOT_YOUR_TURN` | "Chưa đến lượt bạn." | +| `TIMEOUT` | "Hết giờ!" | + +**Theming:** CSS custom properties on `:root`, `[data-theme="dark"]` override, set from the +settings store before first paint to avoid a flash. + +## Related Code Files + +- Create: `web/` — SvelteKit scaffold (`package.json`, `svelte.config.js`, `vite.config.js`, `jsconfig.json`) +- Create: `web/src/lib/ws/client.js`, `web/src/lib/ws/messages.js` +- Create: `web/src/lib/stores/game.svelte.js`, `web/src/lib/stores/settings.svelte.js` +- Create: `web/src/lib/i18n/vi.js` +- Create: `web/src/lib/components/*.svelte` (per list above) +- Create: `web/src/routes/+layout.svelte`, `+page.svelte`, `play/+page.svelte`, `online/+page.svelte` +- Create: `web/src/app.css` — tokens, light/dark palettes +- Create: `web/src/lib/ws/client.test.js`, `web/src/lib/stores/game.test.js` (Vitest) +- Modify: `Makefile` — `make web`, `make web-dev` +- Modify: `server/internal/wsapi/server.go` — serve `NOITU_WEB_DIR` with SPA fallback + +## Implementation Steps + +1. Scaffold with `npx sv create web` (SvelteKit 2 / Svelte 5, JavaScript, Vitest, ESLint+Prettier); switch to `adapter-static` with `fallback: 'index.html'`. +2. Wire `make proto` output into `web/src/lib/proto`; add `@bufbuild/protobuf`. +3. `ws/client.js`: connect (`ws://` in dev via Vite proxy, same-origin `wss://` in prod), binary frames, decode to `ServerMessage`, dispatch to the store, ping/pong clock offset, backoff reconnect, `resume_token` in `sessionStorage`. +4. `stores/game.svelte.js`: `$state` model (phase, chain, currentSyllable, myTurn, deadline, scores, lastRejection, gameOver); one reducer per `ServerMessage` variant. +5. `stores/settings.svelte.js`: nickname, theme (system default, then explicit), `bestScore[difficulty]`, all `localStorage`-backed with try/catch so private-mode browsing still works. +6. Components: `ChainHistory` (scrolling word list, most recent pinned and highlighted, syllable-count badge on 3+ syllable words), `WordInput` (IME-safe, disabled when not your turn, autofocus on turn start), `CountdownRing` (SVG arc from the derived remaining time, colour shift under 5s), `NicknameInput` (20-char cap, mirrors the server rule), `ScoreBoard`, `DifficultyPicker`, `ConnectionBadge`, `GameOverPanel`, `ThemeToggle`, `AttributionFooter`. +7. Routes: home (nickname + mode + difficulty), `play` (the board, works for bot now and PvP in phase 7), `online` stub. +8. `i18n/vi.js` with every string and the rejection map; assert in a test that each proto `RejectReason` has an entry. +9. Personal best: on `GameOver`, compare and store per difficulty; show a "kỷ lục mới" marker. +10. `AttributionFooter`: names `minhqnd/dictionary`, links the repo and the CC BY-SA 4.0 deed — the user-visible half of the license obligation. +11. Vitest: store reducers for each server message, reconnect backoff schedule, rejection-map completeness, high-score persistence with `localStorage` throwing. +12. Verify the production bundle contains no word data (`grep` the built assets for known words) and record the bundle size. + +## Success Criteria + +- [ ] Full vs-bot game playable in a browser at all three difficulties +- [ ] Words of 2, 3, and 4 syllables are all accepted and scored, with the syllable bonus visible +- [ ] Nickname persists across reloads; the displayed name is always `accepted_nickname` from the server, never the raw input +- [ ] Every rejection reason renders its specific Vietnamese message +- [ ] Countdown matches the server deadline within ~200ms; expiry is announced by the server, not the client +- [ ] Dark mode persists across reloads with no flash of the wrong theme +- [ ] Personal best per difficulty persists and updates +- [ ] Killing the server mid-game shows a connection state and reconnects when it returns +- [ ] Diacritics typed with a Telex IME enter correctly on desktop and mobile +- [ ] `npm run build` → `adapter-static` output served correctly by the Go binary, deep links included +- [ ] Built assets contain no dictionary words +- [ ] Attribution footer present with working links +- [ ] `npm test` green + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| IME composition broken by input handling | Diacritics mangled while typing | Never rewrite the input value during composition; guard with `compositionstart`/`end`. Explicitly tested on a real mobile keyboard, not just a desktop emulator | +| Countdown drift makes a legitimate move look late | Player sees 2s left, server says timeout | Clock offset from `Ping`/`Pong`; the ring visually settles ~300ms early so the client never claims more time than the server allows | +| Svelte 5 runes in `.svelte.js` store files misused | Reactivity silently stops updating | Stores use the documented `.svelte.js` rune-module pattern; store reducers are unit-tested outside components | +| Protobuf runtime bloats the bundle | Bundle budget exceeded | Measured in step 12; `protobuf-es` is tree-shakeable and only generated messages are imported | +| `localStorage` unavailable (private mode / blocked) | Crash on load | Every read/write wrapped in try/catch with a working in-memory default | +| Dev/prod WS URL divergence | Works in `npm run dev`, breaks when served by Go | Same-origin `wss://` resolved from `location` in prod; Vite proxy only in dev; both paths exercised before phase 7 | diff --git a/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md b/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md new file mode 100644 index 0000000..1e0455c --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/phase-07-online-1v1-and-release.md @@ -0,0 +1,144 @@ +--- +title: "Phase 7: Online 1v1 and Release" +status: todo +phase: 7 +priority: P1 +effort: "4d" +dependencies: [5, 6] +--- + +# Phase 7: Online 1v1 and Release + +## Overview + +Complete the online 1v1 experience end to end in the browser — room codes, waiting room, +opponent presence, reconnect UX, rematch — then verify the whole game with browser E2E tests +and ship it: CI, container image, deployment, and documentation. + +## Requirements + +**Functional** +- [ ] Create a room → shareable 6-character code and a copyable invite link +- [ ] Join by code, or by opening an invite link with the code prefilled +- [ ] Waiting room until the opponent arrives; leaving cleans the room up +- [ ] Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only) +- [ ] Live opponent state: their turn, their timer, their disconnect and reconnect +- [ ] Resign, and rematch in the same room after a game ends +- [ ] Clear Vietnamese error states: room not found, room full, game already started + +**Non-functional** +- [ ] E2E coverage of both modes with two real browser contexts +- [ ] CI: Go tests + race, JS tests, `buf lint`/`breaking`, generated-code drift check, builds +- [ ] Deployable artifact: container image with the binary, the static frontend, and `noitu.db` +- [ ] README documents setup, the license split, and deployment + +## Architecture + +No new server concepts — phase 5 already implements rooms, codes, reconnect, and resign. +This phase is the frontend surface for them plus release engineering. + +**Invite link:** `https:///online?code=ABC123`. The `online` route reads the query +param, prefills, and auto-joins when the code is well-formed. Room codes use an unambiguous +alphabet (no `0/O/1/I`) and are displayed grouped for reading aloud. + +**Online screen states** + +``` +idle ──create──► waiting (code shown, copy button) + │ └──opponent joins──► playing + └──join(code)──► playing | error(room_not_found | room_full | already_started) + +playing ──opponent disconnects──► banner "Đối thủ mất kết nối… (Ns)" + ├─ they return ──► resume playing + └─ grace expires ──► GameOver(END_OPPONENT_LEFT) +``` + +**Rematch:** after `GameOver` in a PvP room, either player may request a rematch; the room +resets its engine with a fresh opening word when both accept, or closes on decline/timeout. +Requires a small additive protocol change — `RequestRematch` / `RematchState` — which is why +it lands here rather than in phase 4: additive tags only, per the phase-4 compatibility rule. + +**Deployment — Docker image on a VPS behind a reverse proxy (validated decision):** + +``` +Dockerfile (multi-stage) + node → npm ci && npm run build → /web/build + go → CGO_ENABLED=0 go build ./cmd/... → /noitu-server + data → curl the 179 MB dictionary.db → build-dictionary → /data/noitu.db + final → distroless/static + binary + /web + /data/noitu.db + /data/LICENSE + ATTRIBUTION +``` + +`CGO_ENABLED=0` works because the SQLite driver is `modernc.org/sqlite` (pure Go) — the +payoff for that phase-1 decision. The 179 MB upstream download happens **only in the data +builder stage**, so it never reaches the final image; `noitu.db` is copied in as its own +layer, keeping the CC BY-SA 4.0 artifact physically distinct from the Apache-2.0 binary. + +**CI data strategy (consequence of not republishing a derived DB):** CI must never download +179 MB per run. + +| Job | Data used | +|---|---| +| Unit tests (every push/PR) | Small fixture DB built in-test from a checked-in word sample (phase 2 helper) | +| E2E tests (every push/PR) | Same fixture DB, ~200 curated words — enough to script deterministic games | +| Full dictionary build (manual / nightly) | Real upstream, with the download cached by release tag | +| Docker image build (release only) | Real upstream, inside the builder stage | + +## Related Code Files + +- Modify: `proto/noitu/v1/game.proto` — add `RequestRematch`, `RematchState` (new tags only) +- Modify: `server/gen/...`, `web/src/lib/proto/...` — regenerate +- Modify: `server/internal/wsapi/room.go` — rematch handling, room reset +- Modify: `web/src/routes/online/+page.svelte` — replace the phase-6 stub with the full create/join/waiting/error flow +- Create: `web/src/lib/components/RoomCodePanel.svelte`, `WaitingRoom.svelte`, `OpponentStatus.svelte`, `RematchPrompt.svelte` +- Modify: `web/src/lib/stores/game.svelte.js` — room/opponent/rematch state +- Modify: `web/src/lib/i18n/vi.js` — online-mode strings +- Create: `e2e/bot-game.spec.js`, `e2e/pvp-game.spec.js`, `e2e/reconnect.spec.js` (Playwright) +- Create: `playwright.config.js` +- Create: `Dockerfile`, `.dockerignore` +- Create: `.github/workflows/ci.yml` +- Modify: `README.md` — quickstart, architecture, deployment, license split +- Create: `docs/deployment.md` + +## Implementation Steps + +1. Add `RequestRematch` / `RematchState` to the proto with new tags; regenerate both targets; extend the round-trip tests. +2. Server: rematch in `room.go` — both-accept resets the engine with a new opening word and re-emits `GameStarted`; decline or timeout closes the room. Cover with a room test. +3. `online/+page.svelte`: the state machine above, code input (uppercase, alphabet-restricted, paste-friendly), `?code=` prefill and auto-join. +4. `RoomCodePanel`: large grouped code, copy button, copy invite link, Web Share on mobile where available. +5. `WaitingRoom`: opponent-pending state with a cancel that leaves the room cleanly. +6. `OpponentStatus`: turn indicator, disconnect banner with the grace countdown, reconnect confirmation. +7. `RematchPrompt` on the game-over panel for PvP; bot mode keeps a plain "chơi lại". +8. Vietnamese strings for every new state, including the three join errors. +9. Playwright setup: build the frontend, run the real server against the **~200-word fixture DB** (never the real dictionary — CI must not download 179 MB), run specs against it. +10. E2E `bot-game.spec.js`: pick Hard, play scripted valid and invalid words, assert rejection messages, play to game over, assert the score and personal-best marker. +11. E2E `pvp-game.spec.js`: two browser contexts with different nicknames, create + join by code, assert each side shows the other's sanitized nickname, alternate turns, assert each side sees the other's word, resign ends it correctly. +12. E2E `reconnect.spec.js`: drop one context's socket mid-game, assert the opponent's disconnect banner, restore within grace, assert the board matches on both sides. +13. `Dockerfile` multi-stage as above; verify the image runs with only `NOITU_DB_PATH` set. +14. `ci.yml`: `go vet`, `go test ./... -race`, `npm test`, `npm run build`, `buf lint`, `buf breaking`, generated-code drift check, Playwright — all on the fixture DB. Separate release-only job builds the Docker image. Add an assertion that the built image contains `data/LICENSE` and `data/ATTRIBUTION.md`. +15. `README.md`: what the game is, quickstart (**`make fetch-dict` downloads 179 MB once**, then `make dict`), `make` targets, architecture diagram, **License** section (Apache-2.0 code / CC BY-SA 4.0 data with attribution), and a link to `data/ATTRIBUTION.md`. +16. `docs/deployment.md`: env vars (incl. `NOITU_TURN_LIMIT=20s`, `NOITU_ALLOWED_ORIGINS`), TLS/`wss://` behind a reverse proxy (`Upgrade`/`Connection` headers, proxy read timeout longer than the WS keepalive, buffering disabled), health check, and how `noitu.db` reaches the container. + +## Success Criteria + +- [ ] Two people on different machines play a full game via a shared room code +- [ ] Invite link opens straight into the room +- [ ] All three join errors show correct Vietnamese messages +- [ ] Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win +- [ ] Rematch restarts in the same room with a new opening word +- [ ] Resign ends the game immediately with the right winner +- [ ] Playwright suite green: bot game, PvP game, reconnect +- [ ] CI green on all steps including the generated-code drift check, without ever downloading the 179 MB upstream DB +- [ ] `docker run` with `NOITU_DB_PATH` serves a playable game; the image contains `data/LICENSE` and `data/ATTRIBUTION.md` but not the 179 MB upstream file +- [ ] README license section and in-app attribution agree with `data/ATTRIBUTION.md` +- [ ] Full pass over `plan.md` success criteria — every box checkable + +## Risk Assessment + +| Risk | Signal | Response | +|---|---|---| +| Reverse proxy breaks WS upgrade in production | Works locally, 400/502 on deploy | `docs/deployment.md` states the required proxy headers and timeouts; the health check plus a post-deploy WS smoke test catch it before users do | +| Playwright PvP tests flake on timing | Intermittent CI red | Drive by deterministic server events (wait for the turn indicator), never fixed sleeps; run the suite with a long turn limit via env | +| Rematch protocol addition breaks phase-6 clients | Decode errors in an already-running client | Additive tags only; `buf breaking` in CI is the guard, and `protocol_version` lets the server reject a stale client cleanly | +| Room codes guessable enough to join a stranger's game | Reports of uninvited joins | `crypto/rand` codes plus per-IP join rate limiting from phase 5; 32^6 space with rate limiting makes scanning impractical | +| CC BY-SA obligations lost during packaging | Image ships without `data/LICENSE` | The Docker copy step includes the whole `data/` directory, and a CI assertion checks `data/LICENSE` and `data/ATTRIBUTION.md` exist in the built image | +| Scope creep into accounts/leaderboards at the finish line | New requests during release work | Explicit non-goals in `plan.md`; log them as post-v1 items instead | diff --git a/plans/260904-1125-noi-tu-web-game/plan.md b/plans/260904-1125-noi-tu-web-game/plan.md new file mode 100644 index 0000000..b3a2253 --- /dev/null +++ b/plans/260904-1125-noi-tu-web-game/plan.md @@ -0,0 +1,281 @@ +--- +title: "Noi Tu Web Game" +description: "Vietnamese nối từ web game — SvelteKit frontend, Go backend, WebSocket + Protobuf, server-authoritative dictionary over SQLite. Vs-bot and online 1v1." +status: pending +priority: P1 +effort: "~3-4w" +tags: [game, sveltekit, go, websocket, protobuf, sqlite, vietnamese] +created: 2026-09-04 +blockedBy: [] +blocks: [] +--- + +# Noi Tu Web Game + +## Overview + +Web implementation of **nối từ**, the Vietnamese word-chain game: a player submits a +meaningful word of **at least 2 syllables** whose **first syllable equals the previous +word's last syllable**. No reuse, timed turns. Loss on timeout, invalid word, wrong link, +repeat, or no legal move remaining. + +Two modes ship in v1: **vs bot** (3 difficulties) and **online 1v1**. Both run through the +same server-authoritative engine — the browser never holds the wordlist, so validation +cannot be bypassed and the bot and PvP paths share one code path. + +**Stack (user-selected):** SvelteKit (JavaScript) frontend · Go backend · WebSocket +transport with Protobuf framing · dictionary served from SQLite server-side. + +**Research basis:** [`plans/reports/research-260904-1058-noi-tu-game.md`](../reports/research-260904-1058-noi-tu-game.md) + +## Goals + +| # | Goal | Priority | +|---|------|----------| +| 1 | Correct, server-authoritative nối từ rules incl. Vietnamese text normalization | P1 | +| 2 | Reproducible dictionary pipeline from `minhqnd/dictionary` with correct CC BY-SA 4.0 compliance | P1 | +| 3 | Typed WS protocol (Protobuf) shared by Go and JS, single source of truth | P1 | +| 4 | Playable vs-bot mode with 3 difficulty levels | P1 | +| 5 | Online 1v1 with nicknames, room codes, turn timer, reconnect | P1 | +| 6 | Vietnamese UI, dark mode, local high score | P2 | +| 7 | Single deployable Go binary, shipped as a Docker image | P2 | + +## Non-goals (v1) + +- Word definitions / meanings display (explicitly out of scope per user selection) +- Accounts, auth, persistent profiles, server-side leaderboards +- 4-player *đấu trường* mode +- Native mobile apps +- Player-submitted word additions / dictionary moderation UI +- Redistributing our derived dictionary as a downloadable artifact (see Data Distribution) + +## Architecture + +``` +┌──────────────────────────────┐ ┌────────────────────────────────────┐ +│ SvelteKit SPA (adapter- │ WSS │ Go server (single binary) │ +│ static, Svelte 5 runes) │ ◄─────► │ │ +│ │ Protobuf│ wsapi ── hub ── room(s) │ +│ lib/ws connection+codec │ binary │ │ │ +│ lib/proto protobuf-es gen │ frames │ ├── game.Engine │ +│ lib/stores game state │ │ └── bot.Bot │ +│ routes/ UI (vi, dark mode)│ │ │ │ +└──────────────────────────────┘ │ dictionary.Store ──┘ │ + localStorage: nickname, high score, │ │ read-only SQL │ + theme │ data/noitu.db (CC BY-SA 4.0) │ + └────────────────────────────────────┘ + ▲ built locally by + server/cmd/build-dictionary (Go) + ▲ reads + dictionary.db (179 MB, downloaded from + minhqnd/dictionary release v2.0.0) +``` + +**Key decisions** + +| Decision | Choice | Rationale | +|---|---|---| +| Authority | Server validates every move; client is a view | User chose server-side dictionary; also the only way PvP is cheat-resistant | +| Word length | **≥ 2 syllables**, link on first↔last syllable | User decision. Closer to casual play; keeps 3+ syllable compounds in the corpus | +| Bot location | Server-side, same engine as PvP | One rule implementation, no duplication (DRY); bot "thinking delay" is server-timed | +| Turn limit | **20s**, identical for bot and PvP | One constant, one code path; `NOITU_TURN_LIMIT` overrides | +| Player identity | **User-typed nickname**, no accounts | User decision. Stored in `localStorage`, sanitized server-side | +| WS library (Go) | `github.com/coder/websocket` v1.8.15 (ISC) | `gorilla/websocket` archived 2022 and panics on concurrent writes; coder/websocket is context-aware and handles concurrent writes | +| Protobuf (JS) | `@bufbuild/protobuf` v2.14.1 + `protoc-gen-es` | Only fully conformant JS impl, ESM/tree-shakeable, small browser bundle | +| Protobuf (Go) | `protoc-gen-go` via same `buf.gen.yaml` | One schema, two targets, generated in CI | +| Go module path | `github.com/tiennm99dev/noitu/server` | Matches the actual git remote | +| SQLite driver | `modernc.org/sqlite` (CGo-free) | Keeps `CGO_ENABLED=0` cross-compilation and a distroless image; read-only lookups are not CGo-bound | +| Dictionary DB | Loaded at runtime from `data/noitu.db`, **not** `go:embed` | Keeps CC BY-SA 4.0 data a separate artifact from Apache-2.0 code — cleanest license boundary | +| Tone variants | Alias table built offline (`hoà`→`hòa`) | Precomputed lookup beats a fragile runtime tone-placement algorithm (KISS) | +| Room state | In-memory, no DB | v1 has no persistence requirement; restart drops live games (accepted, documented) | +| Deployment | Docker image on a VPS behind a reverse proxy | Most portable; phase 7 ships a distroless image and proxy docs | + +## Data Distribution + +**Decision (validated):** we do **not** republish a derived dictionary. Every build downloads +`dictionary.db` from the upstream release and derives `data/noitu.db` locally. + +Upstream asset: [`minhqnd/dictionary` release v2.0.0 → `dictionary.db`](https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db) — **179 MB**. + +Consequences, all handled explicitly: + +| Consequence | Handling | +|---|---| +| 179 MB is too large to fetch on every CI run | CI runs tests against a **small fixture DB** built from a checked-in word sample (phase 2). Only a manually-triggered/nightly job downloads the real upstream and builds the full DB | +| Docker build needs the upstream DB | Downloaded in the **builder stage** only; the final image carries just the derived `noitu.db`, so the 179 MB never ships | +| `data/noitu.db` is a local build artifact | Git-ignored. `make dict` is a documented prerequisite for running the real server | +| The derived DB *is* distributed inside our Docker image | CC BY-SA 4.0 still applies to that image layer — attribution files ship with it, asserted in CI (phase 7) | + +## Licensing (mandatory — CC BY-SA 4.0 share-alike) + +`minhqnd/dictionary` is **dual-licensed**: MIT for application code, **CC BY-SA 4.0 for the +dictionary data**. We consume only the data, so share-alike applies to our derived wordlist +wherever we distribute it (notably the Docker image). + +| Artifact | License | File | +|---|---|---| +| All source code in this repo | Apache-2.0 (existing) | `LICENSE` | +| Derived dictionary `data/noitu.db` and the build script's output | **CC BY-SA 4.0** | `data/LICENSE` | +| Attribution + list of modifications | — | `data/ATTRIBUTION.md` | +| Root pointer to the split | — | `NOTICE`, README section | +| User-visible credit | — | in-app footer with link | + +Attribution must name: `minhqnd/dictionary`, its own upstream sources (Wiktionary, `vntk/dictionary`), +the CC BY-SA 4.0 license URL, and **what we changed** (filtered to Vietnamese entries of ≥2 +syllables, NFC-normalized, tone-variant aliases added, first/last syllable columns and indexes +added, all non-`vi` languages and all definitions/translations dropped). + +## Phases + +| # | Phase | Status | Depends on | +|---|-------|--------|-----------| +| 1 | [Foundations and Data Pipeline](./phase-01-foundations-and-data-pipeline.md) | Pending | — | +| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Pending | 1 | +| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Pending | 2 | +| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Pending | 1 | +| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Pending | 3, 4 | +| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 4, 5 | +| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Pending | 5, 6 | + +Phases 2-3 and 4 are independent after phase 1 and can run in parallel if desired. + +## Target Repository Layout + +``` +LICENSE # Apache-2.0 — code only +NOTICE # points at data/ licensing +README.md +buf.yaml buf.gen.yaml +proto/noitu/v1/game.proto # single protocol source of truth +data/ + LICENSE # CC BY-SA 4.0 full text + ATTRIBUTION.md + dictionary.db # upstream, 179 MB, git-ignored, downloaded + noitu.db # derived build artifact, git-ignored +server/cmd/build-dictionary/ # Go: dictionary.db -> noitu.db +server/ # module github.com/tiennm99dev/noitu/server + go.mod + cmd/noitu-server/main.go + internal/vietnamese/ # normalize.go, syllable.go + internal/dictionary/ # store.go — read-only SQLite lookups + internal/game/ # engine.go, state.go, rules.go + internal/bot/ # bot.go, strategy_*.go + internal/wsapi/ # hub.go, room.go, session.go, codec.go + gen/noitu/v1/ # protoc-gen-go output +web/ + src/lib/proto/ # protobuf-es output + src/lib/ws/ # socket client, reconnect, codec + src/lib/stores/ # game state (Svelte 5 runes) + src/lib/components/ + src/routes/ +``` + +## Success Criteria + +- [ ] `server/cmd/build-dictionary` reproducibly turns the upstream `dictionary.db` into `data/noitu.db`; entry count and ≥2-syllable purity asserted +- [ ] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent +- [ ] Go engine unit tests cover: wrong link, unknown word, reuse, single-syllable input, timeout, no-legal-move, and the tone-variant cases `hoà/hòa`, `thuý/thúy`, `quí/quý` +- [ ] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable +- [ ] One `.proto` generates working Go and JS clients; no hand-written message types +- [ ] Vs-bot playable end to end at all 3 difficulties; Hard bot wins measurably more than Easy over 100 simulated games +- [ ] Online 1v1: two browsers join by room code with chosen nicknames, alternate turns, server-enforced 20s timer, correct win/loss, reconnect within grace window restores the game +- [ ] Vietnamese UI throughout; dark mode toggle persists; nickname and high score persist in localStorage +- [ ] `go build` produces one binary; `web` builds to static assets served by that binary +- [ ] No wordlist reachable from the client bundle (verified by inspecting the built assets) +- [ ] CI runs green without ever downloading the 179 MB upstream DB + +## Risk Assessment + +| Risk | Signal it happened | Response | +|---|---|---| +| Upstream `dictionary.db` yields too few clean ≥2-syllable entries (<40k) | Build script count assertion fails | Union with Viet74K multi-syllable filter (see research report §5) — decided before implementation, not mid-phase | +| Tone-variant aliasing misses real-world spellings | Players report valid words rejected | Alias table is data, not code: regenerate with an expanded rule set and rebuild `noitu.db`; add a rejected-word log to find gaps | +| CC BY-SA share-alike misapplied to code | License review flags the Apache/CC mix | Data stays a runtime-loaded separate artifact, never `go:embed`-linked; boundary documented in `NOTICE` and asserted in the image | +| Hard bot is unbeatable, players quit | Playtest win rate ≈0% vs Hard | Cap Hard's killer-syllable play rate; the difficulty ladder is tunable constants, not structure | +| In-memory rooms lost on deploy/restart | Live games drop | Accepted for v1 and stated in-app ("máy chủ đang khởi động lại"); persistence is a post-v1 item | +| Protobuf schema churn breaks a deployed client | Client decode errors after deploy | Additive-only field changes, reserved tags, version field in the handshake; server rejects unknown protocol versions with a clear message | +| Nicknames become an abuse surface (shown to strangers) | Offensive names reported | Server-side sanitization: length cap, control-char strip, whitespace collapse, empty → auto-name. A denylist is a cheap post-v1 addition if needed | +| Allowing 3+ syllable words admits phrases that feel wrong | Playtest complaints about unnatural entries | Builder supports a max-syllable cap and a denylist file; both are data changes, no code change | + +## Validation Log + +### Session 1 — 2026-09-04 + +**Verification Results** +- Tier: Full (7 phases) +- Claims checked: 14 · Verified: 11 · Failed: 1 · Imprecise: 2 · Unverified (env): 1 + +| Claim | Result | Evidence | +|---|---|---| +| proto `go_package` = `github.com/tiennm99/noitu/server/...` | **FAILED** | `git remote -v` → `github.com/tiennm99dev/noitu.git`. Corrected to `github.com/tiennm99dev/noitu/server` | +| `coder/websocket` provides `SetReadLimit`, `AcceptOptions.OriginPatterns`, `MessageBinary` | VERIFIED | pkg.go.dev, v1.8.15, ISC | +| "Read deadline per frame" via a socket deadline setter | **IMPRECISE** | coder/websocket exposes no `SetReadDeadline`; deadlines are `context.WithTimeout` on `Read`. Phase 5 corrected | +| `dictionary.db` downloadable from `minhqnd/dictionary` Releases | VERIFIED | GitHub API: release `v2.0.0`, asset `dictionary.db` | +| Upstream DB size assumed manageable | **IMPRECISE** | Asset is **179 MB**. Plan had no size claim; Data Distribution section added | +| `@bufbuild/protobuf` / `@bufbuild/protoc-gen-es` current | VERIFIED | npm registry, both v2.14.1 | +| Go ≥1.24, Node, Docker available locally | VERIFIED | go1.26.5, node v24.18.0, Docker 29.6.2 | +| `buf` installed locally | **UNVERIFIED (env gap)** | `buf: command not found`. Not blocking — generated code is committed, so `buf` is needed only to change the schema | + +**Decisions confirmed** + +| # | Question | Decision | Impact | +|---|---|---|---| +| 1 | Go module path | `github.com/tiennm99dev/noitu/server` | Phase 4 `go_package` corrected | +| 2 | Derived DB distribution | Do not republish — every build downloads upstream and derives locally | New Data Distribution section; phase 1 download step, phase 7 CI + Docker strategy | +| 3 | Turn time limit | 20s, identical for bot and PvP | Phase 5 default constant | +| 4 | Deployment target | Docker on a VPS behind a reverse proxy | Phase 7 docs scoped to proxy config, not a PaaS | +| 5 | Word length rule | **≥ 2 syllables** (changed from strict 2) | Phases 1, 2, 3, 4, 6 — filter, API, reject reason, proto enum, UI copy | +| 6 | Player identity in PvP | **User-typed nickname** | Phases 4, 5, 6, 7 — proto field, sanitization, input UI, display | + +**Phase propagation:** phases 1-7 all updated. Reject reason renamed +`NOT_TWO_SYLLABLES` → `TOO_FEW_SYLLABLES` across the engine, proto, and Vietnamese copy. + +### Whole-Plan Consistency Sweep + +Re-read `plan.md` + all 7 phase files after propagation. + +| Check | Result | +|---|---| +| Stale "2-syllable"/"exactly 2" claims | Resolved — all now "≥2 syllables" / "at least 2" | +| `NOT_TWO_SYLLABLES` enum name | Resolved — renamed everywhere (engine, proto, i18n) | +| `go_package` module path | Resolved — single correct value in phase 4 | +| Turn limit stated inconsistently | Resolved — 20s in plan.md + phase 5, `NOITU_TURN_LIMIT` override | +| Nickname referenced but never sourced | Resolved — proto field, sanitization, input, and display now specified | +| `online/+page.svelte` Create vs Modify across phases 6/7 | Resolved — phase 6 creates the stub, phase 7 modifies it | +| Data distribution vs Docker/CI steps | Resolved — consistent across plan.md, phase 1, phase 7 | +| Duplicate embedded proto schema | Single copy, in phase 4 only | + +**Unresolved contradictions: none.** + +## Phase 1 Outcome (2026-09-04) + +Built and verified against the real upstream release. + +| Metric | Value | +|---|---| +| Words | 48,216 (floor 40,000) | +| 2 / 3 / 4+ syllables | ~40k / ~4.5k / ~3.5k | +| Aliases | 2,041 | +| Derived DB | ~3 MB, from a 179 MB source | +| Coverage | `internal/vietnamese` 100%, `cmd/build-dictionary` 82.4% | + +Upstream schema auto-detected (`words.word` / `lang_code`) with no flag override needed, +retiring the phase-1 "unknown upstream schema" risk. Upstream asset pinned by SHA-256. + +**Four defects were found and fixed in alias generation, three of them only visible by +inspecting real output rather than fixture tests:** `quý`→`qúy`, `gì`→`gỳ`, +`hoàn`→`hòan` (tone shift must be confined to open syllables), and an i/y rule so broad it +invented ~340 non-words such as `chức vỵ`. A fifth, opposite defect was found by review: +varying one syllable at a time never produced `hóa lý`, the spelling most people type. +Variants are now generated as a cross product. + +A Vietnamese phonotactic check (closed onset/nucleus/coda inventories) now rejects +loanwords the multilingual source tags as Vietnamese — `credit card`, `world cup`, +`come out`. Its first version wrongly rejected the entire `gì`/`gỉ`/`gìn` family by +greedily matching the `gi` digraph; it backtracks over onset candidates now. + +## Open Questions + +1. Does the losing player see the words the bot *could* have played (a teaching feature), or just the result? Plan currently assumes just the result. +2. Should the builder cap maximum syllables (e.g. reject 5+ syllable entries as phrases rather than words)? Plan currently applies no upper bound; the cap exists in the builder as a flag if playtesting says otherwise. +3. Domain name / TLS certificate source for the VPS deployment. diff --git a/plans/reports/research-260904-1058-noi-tu-game.md b/plans/reports/research-260904-1058-noi-tu-game.md new file mode 100644 index 0000000..2773ff6 --- /dev/null +++ b/plans/reports/research-260904-1058-noi-tu-game.md @@ -0,0 +1,201 @@ +# Research Report: Vietnamese "Nối Từ" Word-Chain Game + +Conducted: 2026-09-04 10:58 (Asia/Saigon) · Repo: `D:/tiennm99dev/noitu` (empty, initial commit) + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Methodology](#methodology) +3. [Game Rules](#1-game-rules) +4. [Data Model & Core Algorithms](#2-data-model--core-algorithms) +5. [Bot AI](#3-bot-ai) +6. [Vietnamese Text Normalization](#4-vietnamese-text-normalization-the-real-bug-source) +7. [Dictionary Sources](#5-dictionary-sources-ranked) +8. [Implementation Recommendations](#6-implementation-recommendations) +9. [Common Pitfalls](#7-common-pitfalls) +10. [References](#references) +11. [Open Questions](#open-questions) + +## Executive Summary + +Nối từ = Vietnamese word-chain. Player says a **2-syllable** meaningful word; next player must say a 2-syllable word whose **first syllable equals the previous word's last syllable**. No reuse. Fail to answer in time → lose. Example: `ngôn ngữ → ngữ pháp → pháp luật → luật lệ`. + +Implementation is trivial as a game loop; the hard 20% is (a) getting a clean 2-syllable Vietnamese word list, (b) Unicode/tone normalization, (c) a bot that doesn't feel dumb. Model it as a **directed graph**: nodes = syllables, edges = words (`a→b` for word "a b"). A move = traverse an unused edge from the current node. This is exactly **Directed Edge Geography** — PSPACE-complete, so no cheap perfect solver; use heuristic + depth-limited search. + +Best data source: **`minhqnd/Noi-Tu-Discord` → `src/assets/wordPairs.json`** (MIT, ~60k word pairs, already keyed `firstSyllable → [lastSyllables]` — the exact index the game needs). Fallback/expansion: `duyet/vietnamese-wordlist` Viet74K (74k raw, filter to 2-syllable) and vi.wiktionary dumps. + +## Methodology +- Sources: 4 web searches + 1 repo fetch (skill cap 5) +- Date range: 2020–2026; dictionaries current as of Jan 2026 (Wiktionary copy) +- Terms: `luật chơi nối từ`, `vietnamese wordlist github json`, `github bot nối từ thuật toán`, `Viet74K vietnamese two-syllable dataset` + +--- + +## 1. Game Rules + +**Core rule**: next word's first syllable == previous word's last syllable. + +| Rule | Standard | Notes | +|---|---|---| +| Word length | exactly 2 syllables (từ ghép) | most online implementations enforce 2 strictly; some allow ≥2 | +| Validity | must exist in dictionary, meaningful | typically noun/adj compounds | +| Reuse | banned within a round | track a used-set | +| Timeout | 10–30s per turn | loss condition | +| Chain link | on **syllable**, not letter | despite folk phrasing "chữ cái cuối" | +| Start word | random or player-chosen | pick a high-out-degree node so game doesn't die instantly | + +**Loss conditions**: timeout, invalid/unknown word, wrong first syllable, repeated word, no legal move remains. + +**Modes seen in the wild** (Nối từ tiếng Việt app, gamevui, vuanoitu.fun, wordfight.online): +- *Thử thách* — solo, N rounds vs clock + target score +- *Thách đấu* — 1v1 alternating +- *Đấu trường* — 4 players round-robin + +**Design decision needed early**: does the bot lose when it has no move (fair), or does it get to challenge a rare word? Most implementations: no move = bot loses. + +## 2. Data Model & Core Algorithms + +### Graph model +``` +word "pháp luật" => edge pháp ──"pháp luật"──> luật +state = (currentSyllable, usedWords:Set) +legalMoves(s) = { w in adj[s] : w not in usedWords } +``` + +### Index structure (build once at load) +```js +// Map -- full words (or last syllables) +{ "pháp": ["pháp luật", "pháp lý", "pháp danh", ...], "luật": ["luật lệ", "luật sư", ...] } +``` +Lookup O(1); validation O(1) with a `Set` of full normalized words. + +### Validation pipeline +``` +input -> trim -> NFC -> lowercase -> collapse spaces + -> split on space; assert length === 2 + -> assert syllables[0] === currentSyllable + -> assert dictionary.has(word) + -> assert !used.has(word) +``` + +### Dead-end precomputation +- `outDegree[syllable]` = number of words starting with it. +- **Killer syllables**: `outDegree === 0` (or very low) — rare endings. Precompute the list; moving there is an instant win. +- Syllables with outDegree 0 make the *next* player lose immediately. Mark them at build time. + +## 3. Bot AI + +Perfect play = Directed Edge Geography, PSPACE-complete → no exact solver at 60k edges. Practical ladder: + +| Difficulty | Strategy | +|---|---| +| Easy | random legal move | +| Medium | prefer moves ending in a **low out-degree** syllable; avoid handing the player a hub | +| Hard | 1) instant win: any move to `outDegree==0`; 2) negamax depth 3–5 with alpha-beta over remaining edges, eval = `-log(remaining moves for opponent)`; 3) fall back to Medium heuristic | +| Cruel | opening book of known trap chains | + +Cost control: at depth d, branching = out-degree of visited syllables (often <50). Depth 4 is cheap; order moves by ascending opponent out-degree and cap node count. + +Anti-frustration: cap the bot below always-play-the-killer, or players quit. + +## 4. Vietnamese Text Normalization (the real bug source) + +1. **Unicode form** — normalize to **NFC**. `ữ` can be one codepoint or `ư` + combining tilde. Mismatch = false rejections. +2. **Tone placement variants** — `hoà`/`hòa`, `thuý`/`thúy`, `quí`/`quý`. Old-style vs new-style placement are *different codepoints*. Build an alias map or a tone-position canonicalizer, else valid words get rejected. +3. **Case & whitespace** — lowercase, collapse multiple/NBSP spaces. +4. **Syllable split** — Vietnamese syllables are space-delimited; `split(/\s+/)` is correct. Do NOT use a word-segmenter here. +5. **Used-set keying** — key on the normalized full word. +6. **Encoding** — Viet74K ships Unicode *and* TCVN3/ABC variants; take the Unicode one. + +## 5. Dictionary Sources (ranked) + +| # | Source | Content | Format | License | Verdict | +|---|---|---|---|---|---| +| 1 | [minhqnd/Noi-Tu-Discord](https://github.com/minhqnd/Noi-Tu-Discord) `src/assets/wordPairs.json` + `customWords.json` | ~60k 2-syllable pairs, purpose-built for nối từ | JSON `{"từ_đầu": ["từ_cuối", ...]}` | MIT | **Start here.** Already the exact index shape; MIT-safe to vendor | +| 2 | [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) — [Viet74K.txt](https://vietnamese-wordlist.duyet.net/Viet74K.txt) | 74k words, all lengths, dictionary-sorted | plain txt, Unicode + TCVN3 | unclear/aggregated | Expansion set; filter `split(' ').length===2` | +| 3 | [undertheseanlp/dictionary](https://github.com/undertheseanlp/dictionary) | consolidated VN dictionary from the underthesea NLP group | JSON/txt | check repo | Good for definitions / POS filtering (noun+adj only) | +| 4 | [viet-yomitan](https://github.com/onlyduyy/viet-yomitan) | Từ Điển Tiếng Việt Thông Dụng, 42,012 entries | Yomitan dict (JSON in zip) | check | High-quality curated monolingual entries | +| 5 | [Trannosaur/published_dicts](https://github.com/Trannosaur/published_dicts) | vi.wiktionary + en.wiktionary derived, Jan 2026 | JSON | CC BY-SA (Wiktionary) | Attribution required; largest coverage | +| 6 | [vntk/dictionary](https://github.com/vntk/dictionary) | Node package, lookup + examples | npm | check | Runtime lookup, not bulk list | +| 7 | [NNBnh/noi-tu](https://github.com/NNBnh/noi-tu), [lvdat/bot-noi-tu](https://github.com/lvdat/bot-noi-tu) | reference implementations + wordlists | — | check | Cross-check coverage / borrow trap lists | +| 8 | [titoBouzout/Dictionaries](https://github.com/titoBouzout/Dictionaries/blob/master/Vietnamese_vi_VN.txt) | spellcheck syllable list | txt | — | Syllable validation only, not compounds | + +**Recommended pipeline**: vendor #1 as base → union with 2-syllable filter of #2 → optionally POS-filter with #3 → dedupe after NFC normalization → emit `words.json` (Set) and `word-index.json` (adjacency). Keep the build script in-repo so the dataset is reproducible. + +**Licensing**: MIT (#1) is safe to redistribute with attribution. Wiktionary-derived (#5) is CC BY-SA — attribute and isolate in a clearly-marked file if used. + +## 6. Implementation Recommendations + +### Suggested layout (stack-agnostic; repo is empty so nothing is imposed yet) +``` +data/ + raw/ # downloaded sources + words.json # normalized Set of valid 2-syllable words + word-index.json # { firstSyllable: [word, ...] } +scripts/ + build-dictionary.mjs # raw -> normalized artifacts, reproducible +src/ + normalize.js # NFC, tone-variant canonicalization, split + dictionary.js # load, has(), movesFrom() + game-engine.js # state, applyMove, validate, win/lose + bot.js # difficulty strategies +``` + +### Minimal engine sketch +```js +export function createGame({ index, words, startWord }) { + const used = new Set([startWord]); + let current = startWord.split(' ')[1]; + return { + play(raw) { + const w = normalize(raw); + const s = w.split(' '); + if (s.length !== 2) return { ok: false, reason: 'NOT_TWO_SYLLABLES' }; + if (s[0] !== current) return { ok: false, reason: 'WRONG_LINK' }; + if (!words.has(w)) return { ok: false, reason: 'NOT_IN_DICTIONARY' }; + if (used.has(w)) return { ok: false, reason: 'ALREADY_USED' }; + used.add(w); current = s[1]; + return { ok: true, current }; + }, + moves: () => (index[current] ?? []).filter(w => !used.has(w)), + }; +} +``` + +### Build order +1. `build-dictionary.mjs` + normalization — everything depends on data quality. +2. Engine + unit tests on rules (wrong link, reuse, unknown word, timeout). +3. CLI loop (1 human vs bot). +4. Bot difficulty ladder. +5. UI / multiplayer / timer / scoring if in scope. + +## 7. Common Pitfalls +- **Linking on last *letter* instead of last *syllable*** — folk description says "chữ cái cuối", real play links syllables. +- **Skipping NFC** → valid words rejected; unreproducible across OS/keyboards. +- **Ignoring `hoà`/`hòa` tone-placement variants** → biggest source of "my word IS real!" complaints. +- **Dictionary full of 1- and 3+-syllable entries** → filter at build time, not runtime. +- **Bot always plays the killer syllable** → unwinnable; players leave. +- **Reloading a 60k-entry JSON per request** in a server context → load once at boot. +- **No per-session used-set** → infinite `a→b→a→b` loops. +- **Client-side-only validation** in multiplayer → cheatable; validate server-side. + +## References +- Rules: [luatchoi.edu.vn/noi-tu](https://www.luatchoi.edu.vn/noi-tu) · [gamevui.vn](https://gamevui.vn/noi-tu-tieng-viet/game) · [hoanghamobile roundup](https://hoanghamobile.com/tin-tuc/noi-tu-online/) +- Live games: [vuanoitu.fun](https://vuanoitu.fun/) · [wordfight.online](https://wordfight.online/) · [App Store: Nối từ tiếng Việt](https://apps.apple.com/vn/app/n%E1%BB%91i-t%E1%BB%AB-ti%E1%BA%BFng-vi%E1%BB%87t/id6449588406?l=vi) +- Implementations: [minhqnd/Noi-Tu-Discord](https://github.com/minhqnd/Noi-Tu-Discord) · [NNBnh/noi-tu](https://github.com/NNBnh/noi-tu) · [lvdat/bot-noi-tu](https://github.com/lvdat/bot-noi-tu) +- Data: [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) · [Viet74K.txt](https://vietnamese-wordlist.duyet.net/Viet74K.txt) · [undertheseanlp/dictionary](https://github.com/undertheseanlp/dictionary) · [viet-yomitan](https://github.com/onlyduyy/viet-yomitan) · [Trannosaur/published_dicts](https://github.com/Trannosaur/published_dicts) · [vntk/dictionary](https://github.com/vntk/dictionary) · [titoBouzout/Dictionaries](https://github.com/titoBouzout/Dictionaries/blob/master/Vietnamese_vi_VN.txt) +- NLP resources: [vndee/awsome-vietnamese-nlp](https://github.com/vndee/awsome-vietnamese-nlp) + +## Next Steps +1. Decide stack + target (CLI, web, Discord bot, mobile) — nothing in repo constrains this yet. +2. Vendor `wordPairs.json` from Noi-Tu-Discord (MIT) into `data/raw/`, write `build-dictionary.mjs`, verify entry count and 2-syllable purity. +3. Implement `normalize.js` with NFC + tone-placement canonicalization; unit-test `hoà/hòa`, `thuý/thúy`, `quí/quý`. +4. Implement engine + rule tests, then a CLI loop before any UI. +5. Add bot ladder once the engine is green. + +## Open Questions +1. Target platform: CLI, web app, Discord bot, or mobile? +2. Strict 2-syllable only, or allow ≥2-syllable words? +3. Single-player vs bot, or real-time multiplayer? (multiplayer needs a server + authoritative validation — big architecture delta) +4. Are word definitions in scope? (pushes toward source #3/#4/#5) +5. Should max-difficulty bot be beatable, or is "cruel mode" wanted? +6. Vietnamese-only UI, or bilingual?