mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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.
This commit is contained in:
1 parent
ad6c6344dc
commit
582ba27354
9 files changed
+1482
No files matched your search
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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` |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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://<host>/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 |
|
||||
@@ -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.
|
||||
@@ -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<firstSyllable, string[]> -- 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<string>` 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?
|
||||
Reference in new issue
Block a user