docs(plans): drop delivered plan phases, keep journals and reports

This commit is contained in:
tiennm99 committed 2026-09-10 09:56:31 +07:00
1 parent 2f893c9877
commit b1204e270f
42 files changed
-5977

No files matched your search

@@ -1,146 +0,0 @@
---
title: "Phase 1: Foundations and Data Pipeline"
status: done
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 |
@@ -1,150 +0,0 @@
---
title: "Phase 2: Go Dictionary and Normalization"
status: done
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**
- [x] `vietnamese.Normalize(raw) (word string, syllables []string, err error)` — NFC, lowercase, whitespace collapse, syllable split (any syllable count; length rules belong to the engine)
- [x] Store resolves a player-typed variant to its canonical word via `aliases`
- [x] `Store.Resolve(word)` → canonical word + exists
- [x] `Store.WordsStartingWith(syllable)` → words (for bot move generation)
- [x] `Store.OutDegree(syllable)` → int (0 means dead end)
- [x] Store opens the DB **read-only**; concurrent-safe for many goroutines
**Non-functional**
- [x] Lookup ≤ 1ms p99 under 100 concurrent readers
- [x] 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 — loaded fully into memory at Open; no runtime SQL.
type Store struct{ /* maps, all written once during Open */ }
func Open(path string) (*Store, error) // file:...?mode=ro, closed before Open returns
func (s *Store) Resolve(word string) (canonical string, ok bool)
func (s *Store) LastSyllable(word string) (string, bool)
func (s *Store) WordsStartingWith(syl string) []string // shared slice, do not modify
func (s *Store) OutDegree(syl string) (int, error) // ErrNotFound if unknown
func (s *Store) RandomOpeningWord(minOutDegree int) (string, error)
func (s *Store) WordCount() int
func (s *Store) AliasCount() int
func (s *Store) License() string
```
**Revised during implementation — measured, not assumed.** The original design queried
SQLite per lookup and kept only `syllables` in memory. Measurement rejected that: a SQL
round-trip benchmarked at **55us** against **8.9ns** for a map hit. It would also have
threatened phase 3, where the hard bot explores hundreds of candidate moves inside a
150ms budget.
(An earlier draft justified this with a "p99 of 1.0046ms". That figure was wrong — the
Windows clock quantizes `time.Now()` to ~1ms, so it measured timer resolution, not the
code. Cite the benchmark, which does not time individual operations, and do not
reintroduce a latency test built on `time.Now()` around a nanosecond-scale call.)
The whole dictionary is now loaded at `Open` and the database closed immediately.
Measured on the real 48,216-word corpus: **~70ms load, ~7.8 MB heap**, and per operation,
all allocation-free:
| Operation | Cost | Allocations |
|---|---|---|
| `Resolve` | 8.9 ns | 0 |
| `WordsStartingWith` (full iteration) | 10.7 ns | 0 |
| `OutDegree` | 15 ns | 0 |
| `RandomOpeningWord` | 18 ns | 0 |
`Resolve` returns no error (a map lookup cannot fail) and `Close` is gone — there is
nothing left open. The result is both faster and simpler: no connection pool, no prepared
statements, no tail latency.
`WordsStartingWith` returns an `iter.Seq[string]`, not a slice. Returning the backing
slice let a caller sort, shuffle or `append` into dictionary state: verified on the real
corpus, a caller's write landed in the Store, 2,449 of 5,049 buckets had spare capacity
for `append` to scribble into, and `-race` confirmed the write/write race across
goroutines. An iterator removes the hazard structurally rather than by comment.
`Open` validates what it loaded against the builder's `meta.word_count`, cross-checks
every `syllables.out_degree` against the words actually indexed, and rejects orphan
aliases. Without that, a truncated database opens cleanly and the server starts, rejects
every word a player types, and fails every room creation.
**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.**
Canonicalization can move **either end** of the word. In the shipped dictionary roughly
half the aliases differ in the last syllable and more than a third in the first — typing
`sỹ hai` resolves to `sĩ hai`, moving the first syllable from `sỹ` to `sĩ`. An engine that
link-checked against the syllables the player typed would therefore **reject legal moves**.
`Resolve` returns the canonical form and `FirstSyllable`/`LastSyllable` report that form's
ends; phase 3 must use those, never `vietnamese.Normalize`'s split of the raw input.
All lookups are map reads; the database is closed before `Open` returns.
**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
- [x] `go test ./internal/... -race` green
- [x] Composed and decomposed spellings of the same word both resolve to one canonical entry
- [x] Words of 2, 3, and 4 syllables all resolve; a 1-syllable input is rejected by `HasEnoughSyllables`
- [x] `hoà lợi`-style tone variants resolve via `aliases`
- [x] Store refuses to open a missing or writable-mode DB path with a clear error
- [x] Startup log line names the data source and license
- [x] 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 |
@@ -1,184 +0,0 @@
---
title: "Phase 3: Go Game Engine and Bot AI"
status: done
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**
- [x] `Engine.Submit(playerID, raw)` validates: at least 2 syllables → first syllable matches current → word exists → not already used
- [x] Distinct rejection reasons, not a boolean
- [x] Used-word set per game; a word is spent for both players
- [x] Detects `no legal move remains` for the player to act
- [x] Turn deadline held as `time.Time`, enforced by the caller; engine exposes `IsExpired(now)`
- [x] Bot difficulties: Easy (random), Medium (prefer low out-degree), Hard (dead-end win + depth-limited search)
- [x] Score model: points per accepted word, chain length tracked
**Non-functional**
- [x] Zero dependency on `wsapi` or generated protobuf types — engine speaks its own Go types
- [x] Hard bot move decision ≤ 150ms
- [x] 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.
## Revised during implementation — measured, not assumed
| Spec said | Now | Why |
|---|---|---|
| Validation order: turn → length → **link** → dictionary | turn → length → **dictionary** → link → reuse | Phase 2 established that 38% of aliases move the first syllable (`sỹ hai` → `sĩ hai`). The link can only be checked after resolving, or legal moves get rejected. Messages stay accurate either way |
| `dict *dictionary.Store` | `Dictionary` interface in `game` | Lets the engine and bot be tested on hand-built word graphs small enough to reason about, with no SQLite |
| `LegalMoves() ([]string, error)`, `HasLegalMove() (bool, error)` | no error returned | Phase 2's store is in-memory; these cannot fail |
| Medium **never plays a guaranteed kill** | Medium takes a win it can see | Simulation: withholding it made Medium lose to the **random** bot 97% of the time. A strategy that declines to win loses to a coin flip. Difficulty now comes from lookahead depth, not from refusing to play well |
| Eval `-log(1 + opponent move count)` | `+log(1 + moves)` at the node | Negamax evaluates from the perspective of the player *to move*, so the sign inverts. As specified, Hard searched for positions where it was about to be trapped and lost to Medium 71% of the time |
| Bot sleeps a "thinking" pause | `ThinkingDelay()` reported, caller schedules | Sleeping inside `Choose` would make every test wait in real time |
| Strategies take `*game.Engine` | `Board` interface (read-only) | A strategy can read the position but cannot play a move, so it cannot bypass `Submit` validation |
**Measured on the real 48,216-word corpus** (60 games per pairing, alternating sides):
| Matchup | Win rate | Mean game length |
|---|---|---|
| Hard vs Easy | 98% | 3.3 moves |
| Medium vs Easy | 87% | — |
| Hard vs Medium | **65%** | 2.9 moves |
| Easy vs Easy | — | 15.3 moves |
Hard decision latency on the real corpus: p95 12.5ms, **max 18.6ms** against a 150ms budget.
Sides alternate because moving first is an advantage in its own right, and fixed seating
reports that advantage as skill — on a 7-syllable graph with fixed sides the Hard-vs-Medium
figure was exactly 50%, pure first-move effect.
**How much lookahead is worth is a property of the graph, not of the bots.** The synthetic
ladder gave 78% on one seed and 49% on another, so the synthetic test now only guards
against a gross regression (>= 45%) and the real-corpus test carries the claim. Earlier
drafts of this document quoted the synthetic 78% and a 6ms max; both were specific to a
single run and are corrected above.
**Playability signal for phase 6 — the dominant characteristic, not an edge case.** Games
against Hard end in ~3 moves against ~15 for two random bots. The dictionary has 1,814
dead-end syllables, so an instant kill is available at roughly 42% of opening positions and
Hard simply takes one; the search runs in only about half of its decisions. `hardKillRate`
(0.85) softens this but does not remove it. Needs playtesting before release; the levers are
a lower kill rate and dead-end-aware opening selection, both tunable constants.
## 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
- [x] `go test ./internal/game/... ./internal/bot/... -race` green
- [x] Every `RejectReason` has a test that produces exactly it
- [x] A 1-syllable submission yields `ReasonTooFewSyllables`; 3- and 4-syllable words are accepted and score the syllable bonus
- [x] A word played by either player cannot be replayed by the other
- [x] `HasLegalMove` returns false on a synthetic dead-end board and the engine reports the correct winner
- [x] Hard win rate vs Easy > 70% over 100 seeded games; Medium strictly between Easy and Hard
- [x] `BenchmarkHardChoose` ≤ 150ms/op against the real `noitu.db`
- [x] 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 |
@@ -1,226 +0,0 @@
---
title: "Phase 4: Protobuf Contract and Codegen"
status: done
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**
- [x] `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
- [x] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto`
- [x] Protocol version field in the handshake — `Hello.protocol_version`, `Welcome.protocol_version`, and `wsapi.ProtocolVersion = 1`. Rejecting a mismatch lands in phase 5, which is where `Hello` is first read
- [x] Generated code is committed (reviewable diffs, no toolchain needed to build)
**Non-functional**
- [x] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable. Measured against a budget in phase 6, where there is a bundle to measure
- [x] `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** installed (v1.69.0), retiring the plan-time note that it was missing. It stays
> optional for contributors either way: generated code is committed, so `buf` is required only
> when the schema changes.
**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
- [x] `make proto` regenerates both targets with zero diff on a clean tree
- [x] `buf lint` clean; `buf breaking` wired into CI
- [x] Go round-trip test covers every `oneof` variant in both directions
- [x] JS decodes a Go-produced binary fixture and reads correct values
- [x] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart
- [x] 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` |
## Phase 4 Outcome (2026-09-04)
Delivered and verified end to end. `buf lint` clean under the `STANDARD` category with no
carve-outs, regeneration byte-identical, `go test ./... -race` green across every package,
and 23 JavaScript assertions decoding bytes the Go suite produced.
**Two deviations from the sketch above, both accepted before implementation**
| Sketch | Shipped | Why |
|---|---|---|
| `TOO_FEW_SYLLABLES`, `EASY`, `END_TIMEOUT` | `REJECT_REASON_TOO_FEW_SYLLABLES`, `DIFFICULTY_EASY`, `GAME_END_REASON_TIMEOUT` | buf's `STANDARD` lint mandates the enum-name prefix. Keeping the short names would have meant an `ENUM_VALUE_PREFIX` exception, weakening "buf lint clean" to "clean with a carve-out". `protobuf-es` strips the prefix, so the JS side still reads `RejectReason.WRONG_LINK` |
| `web/` untouched until phase 6 | `web/` bootstrapped as a bare npm package | The phase's own JS codegen and Vitest cross-language test have nowhere to live otherwise. Protobuf + Vitest only — no SvelteKit, no routes, no config |
**Additions beyond the sketch**
- `PlayedWord.typed` (field 5). `game.Move` carries both the canonical spelling and the raw
input, and the UI has to show that a correction happened rather than appearing to rewrite
the player's text.
- `REJECT_REASON_GAME_OVER` (7). `game.ReasonGameOver` exists in the engine; without a wire
counterpart the exhaustiveness test fails, which is the test working.
- `wsapi.ProtocolVersion = 1`, giving `protocol_version` a defined value rather than a field
nobody sets.
**Toolchain**
Both code generators resolve from the repo's own manifests — a `tool` directive in
`server/go.mod` and `web/package.json` — so regenerating needs only `buf`, with no globally
installed plugin binaries. `buf.gen.yaml` sets `clean: true`, so the JavaScript wire test
lives in `web/tests/`, outside the generated tree that gets wiped on each run.
**Cross-language checking**
`server/internal/wsapi/wire_test.go` writes 17 binary fixtures to `proto/testdata/` under
`-update`; both suites then read those same bytes. A JS-only round trip would only have
proven that runtime self-consistent, which is exactly the failure that ships a broken client.
`.gitattributes` marks the fixtures `binary`: nine of them contain `0x0a` and no NUL, so a
checkout with `autocrlf` enabled could otherwise rewrite them into messages that no longer
decode.
**Review found four guards that reported green while protecting nothing.** All fixed and
then negative-tested by deliberately breaking each one:
| Defect | Fix |
|---|---|
| `buf breaking --against '.git#branch=main'` fails on every PR — a `pull_request` checkout is a detached HEAD with no local `main` | `.git#ref=origin/main` |
| `git diff --exit-code` never reports untracked files, so a plugin emitting a *new* file would pass the sync check | `git add --intent-to-add` first, in both CI and `make proto-check` |
| The oneof-coverage test compared the schema against a hand-written list of arm names, which drifts in lockstep with the table it duplicates | Coverage now derived from what each sample message actually sets, via `WhichOneof` |
| The exhaustiveness walk ended at `String() == "unknown"`, so a reason added without a `String` case shrank the range under test and still passed | `game.NumRejectReasons` / `game.NumEndReasons` sentinels, plus a test that every reason is named |
`WireDifficulty` was also removed: it had no caller and no message in the schema carries a
`Difficulty`, so its doc comment described a capability the contract cannot express.
**Deferred to phase 5**, where `Hello` is first read: rejecting a mismatched
`protocol_version`, and nickname sanitization behind `Welcome.accepted_nickname`.
@@ -1,204 +0,0 @@
---
title: "Phase 5: Go WebSocket Server and Rooms"
status: done
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**
- [x] `GET /ws` upgrades and runs the session loop; `GET /healthz` for liveness
- [x] Bot rooms (1 human + bot) and PvP rooms (2 humans, joined by 6-char room code)
- [x] Nicknames sanitized server-side and echoed back; opponents only ever see sanitized values
- [x] Server-authoritative 20s turn timer (`NOITU_TURN_LIMIT`); expiry ends the game for the player on turn
- [x] Every accepted move fans out a per-recipient `TurnUpdate`
- [x] Bot moves scheduled on the room goroutine after a randomized thinking delay
- [x] Session resume token issued in `Welcome`, honoured on reconnect within a grace window
- [x] Serves the built frontend as static files so one binary is the whole deployment
**Non-functional**
- [x] One goroutine owns each room's engine — no shared mutable game state (`-race` clean)
- [x] Per-connection read limit, read/write deadlines, and rate limiting on `SubmitWord`
- [x] 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. **Log the dictionary's `WordCount()`, `AliasCount()` and `License()` at startup** — the store deliberately does not log (a library writing to the global logger fights phase 5's structured logging), so the CC BY-SA attribution surfaces here or nowhere.
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
- [x] `go test ./internal/wsapi/... -race` green
- [x] A full bot game is playable end to end over a raw WS client, no frontend involved
- [x] Two in-process clients complete a PvP game with correct alternating turns and a correct winner
- [x] Turn timeout ends the game server-side even if the client sends nothing
- [x] Disconnect + resume within 30s restores the game; after 30s the opponent wins by `END_OPPONENT_LEFT`
- [x] Read limit, rate limit, and origin check each covered by a test
- [x] `sanitizeNickname` tested against: over-length, control chars, zero-width chars, whitespace-only, and empty input; opponents never receive an unsanitized string
- [x] `go build ./cmd/noitu-server` yields one binary that serves both `/ws` and the static frontend
- [x] 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 |
## Phase 5 Outcome (2026-09-05)
Delivered. `go vet` and `go test ./... -race` green across every package; the whole game
is playable over a raw WebSocket with no frontend, and the binary was run against a
fixture dictionary to confirm startup, `/healthz`, the handshake, ping/pong, room
creation and the error keys end to end.
**Two deviations from the spec above, both accepted before implementation**
| Spec | Shipped | Why |
|---|---|---|
| Bot worker searches the live engine | Worker gets an immutable `frozenBoard` — legal moves, a copy of the used set, and the read-only dictionary | `bot.Board.Used` reads engine state, so handing a worker the live engine races every resign and disconnect the room processes while the bot thinks |
| Per-read `context.WithTimeout` on `conn.Read` | No read deadline; liveness is `conn.Ping` every 20s, 10s timeout, close after 2 misses | A read deadline cannot tell a healthy player idling in the lobby from a dead socket, and would disconnect the first |
`NewServer` takes a `Dictionary` interface (the engine's contract plus
`RandomOpeningWord`) rather than `*dictionary.Store`, so tests play whole games against a
four-word graph whose outcome a reader can verify by eye. Tests live in `wsapi_test.go`
and `regression_test.go` rather than the three files sketched above.
**Bugs found by writing the tests**, before review:
| Defect | Consequence |
|---|---|
| Resume token unregistered the instant the socket dropped | A reconnect could never find its seat — the feature was entirely dead |
| `writeLoop` took its write deadline from the cancelled session context | Every refusal queued just before a close was silently discarded |
| `keyedLimiter.sweep` required a full bucket, but tokens refill lazily | Nothing was ever swept: unbounded map growth keyed by whoever connects |
**Review found three more, all proven with repros and all fixed:**
| Defect | Fix |
|---|---|
| The hub bound a joiner to seat `p2` *before* the room decided whether to seat them, and neither `Submit` nor `Resign` checked that the sender owned the seat. Anyone holding a room code — which is pasted into group chats by design — could resign or play on a seated player's behalf | Seating moved onto the room goroutine and happens only on success; `occupies` verifies the sending connection is the one in the seat |
| Cancelling the session context hard-closes the socket under `coder/websocket`, so the shutdown notice never left. Acceptance criterion (i) was failing | Read context separated from the teardown signal and rooted at `Background`, with a watcher that flushes before dropping the socket |
| A room whose game never started had no turn timer and no opponent, so nothing could ever end it; room creation was unlimited | A pre-game disconnect cancels the room; creation is rate-limited per session |
Fixing those exposed a fourth, in `room.send`: a buffered channel and a cancelled context
are both ready, so `select` chose at random and half of all sends into a finished room
reported success. The caller then waited forever for a reply from a goroutine that had
stopped reading. The done-check is its own statement now.
Also corrected: the join limiter keyed on the session id, which a client resets by
reconnecting — it keys on the client IP now, deliberately ignoring `X-Forwarded-For`,
which is attacker-controlled until phase 7 pins a trusted proxy. The static-file guard
compared string prefixes, so `/srv/webhooks` counted as inside `/srv/web`; it is a real
path-boundary check now. Room codes use rejection sampling instead of a modulo, which was
making the first eight letters 12% more likely. The handshake is one-shot.
`sanitizeNickname`'s seat parameter was dead — both players who sent an empty name became
"Người chơi 1" — so the collision is resolved in the room, where both names are known,
and now also covers two players who *choose* the same name.
Every fix has a regression test, and the important ones were mutation-tested by
reintroducing the defect: the authorization guard fails 10/10 against the old code, the
shutdown guard 7/10 and the `room.send` guard 6/10 — both of those are timing races, so
detection is probabilistic by nature.
**Deferred to phase 7:** the trusted-proxy policy for `X-Forwarded-For`, and a room
identity separate from the code if rematch is added, since codes are recycled on eviction.
@@ -1,275 +0,0 @@
---
title: "Phase 6: SvelteKit Frontend"
status: done
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**
- [x] Home screen: nickname input, play vs bot (Easy / Trung bình / Khó), or go to online play
- [x] Nickname persisted in `localStorage`, sent in `Hello`, and replaced by `accepted_nickname` from `Welcome`
- [x] Game screen: chain history, current syllable prompt, input, countdown ring, both scores
- [x] Rejections shown as specific Vietnamese messages, mapped from `RejectReason`
- [x] Game-over screen: result, final score, new-personal-best marker, rematch / home
- [x] Dark mode toggle, persisted; respects `prefers-color-scheme` on first visit
- [x] Personal best per difficulty in `localStorage`
- [x] Connection status indicator; automatic reconnect with backoff
- [x] In-app footer credit for the CC BY-SA 4.0 dictionary source, with links
**Non-functional**
- [x] `adapter-static` build, served by the Go binary
- [ ] Mobile-first; usable one-handed on a phone with the keyboard up
- [x] 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 — the protocol path is verified headlessly at all three; no browser playtest yet
- [ ] Words of 2, 3, and 4 syllables are all accepted and scored, with the syllable bonus visible
- [x] Nickname persists across reloads; the displayed name is always `accepted_nickname` from the server, never the raw input
- [x] 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
- [x] 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
- [x] `npm run build` → `adapter-static` output served correctly by the Go binary, deep links included
- [x] Built assets contain no dictionary words
- [x] Attribution footer present with working links
- [x] `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 |
## Phase 6 Outcome (2026-09-05)
The vs-bot UI is built, typed, tested, and served by the Go binary. 117 JavaScript tests pass
and `svelte-check` reports no errors across 368 files.
### What was verified, and how
| Claim | Evidence |
|---|---|
| The binary serves the SPA, deep links included | `curl` against a running server: `/`, `/play?difficulty=2` and `/online` all return `index.html`; hashed assets and the favicon return their own content types |
| Path traversal cannot escape the bundle | `/../server/go.mod` is normalised to a redirect; the percent-encoded form is refused with 400 |
| A bot game plays end to end at every difficulty | A headless client built from the same generated types played three moves against the bot at Easy, Medium and Hard against a fixture dictionary, with alternating turns and correct running scores |
| The nickname shown is the server's | The handshake returned `accepted_nickname` and the store adopts it |
| No wordlist reaches the browser | A bundle test greps every built file for dictionary words and asserts a size budget |
The bundle is 188,227 bytes across 28 files.
### Deliberate design points
**The store is a reducer over `ServerMessage` and nothing else.** Validity, turn order and the
result are read from the wire, never computed. That is what lets one screen serve both the bot
and, in phase 7, online play.
**The word field is uncontrolled.** A Telex or VNI input method composes a diacritic across
several keystrokes, and writing the value back on each keystroke cancels the composition. The
field is read on submit and cleared only there, where composition has already ended.
**The countdown settles 300ms early.** A ring showing time left while the server has already
timed the player out reads as the game cheating; reaching zero a fraction early reads as
rounding.
**`Hello` is sent before the open status is announced.** The server refuses everything before
the handshake, so a listener reacting to "connected" by sending a message would otherwise race
it. Ordering it explicitly turns a timing accident into a contract.
### Defects found by review
Review found one blocking defect and four user-visible breakages, all in code no test was
driving. The pattern behind the worst of them is worth naming: **the game lifecycle was
inferred from the game model instead of being owned.**
**Every rematch started two bot games, and the second killed the first.** The screen sent
`StartBotGame` from an effect whenever the board was idle. Clearing the board for a rematch is
exactly that condition, so the rematch button sent one request and the inference sent another.
The server allocates a room per request and does not deduplicate, so the player got two rooms
sharing one socket: the abandoned room kept its turn timer, overwrote the turn sequence that
the live game's submissions were tagged with, and about one turn later ended the game in a loss
nobody could explain. A request is now stored intent — `web/src/lib/stores/bot-session.svelte.js`
— and clearing the board cannot mean anything. Twelve tests cover it, because the absence of a
test over this wiring is why it shipped.
**Arriving at the board with a finished game on screen never started a new one.** Same root
cause: nothing reset the store on entry, so the header link or the browser Back button left a
stale phase behind and the idle condition never held. The screen now owns the socket and the
game for as long as it is mounted, and gives the current game up on the way out rather than
leaving a room whose timer keeps running somewhere the player cannot see.
**A word typed during a reconnect vanished silently.** The input stayed enabled while the socket
was down, the send returned false, and the field cleared anyway. The field is now gated on the
connection as well as the turn, and only clears a word that actually went out.
**A protocol bump would have put every open tab into a reconnect loop.** The backoff reset when
the socket opened, but the server accepts the connection and *then* refuses an unspeakable
`Hello`, so every retry looked like a success and restarted from the shortest delay. The
backoff now resets on `Welcome`, and `protocol_version_mismatch` stops reconnection outright.
**The opening turn counted down against the raw device clock.** The first clock probe was
scheduled a full interval out, but `GameStarted` arrives one round trip after `Hello`. A device
clock behind the server would have shown more time than the server allowed — the exact thing
the settle margin exists to prevent. The probe now goes out with the handshake.
Also fixed from the review: cache headers on the static bundle, where an `index.html` cached
across a deploy names hashed assets that no longer exist and the app loads into a blank page;
a record-keeping effect that read and wrote the same reactive state; an animation loop that ran
at 60fps between turns; and the difficulty picker, which claimed a radio role without radio
keyboard behaviour and now uses real radio inputs.
### Defects found while building
**The bundle guard was vacuous on its first negative test.** Planting an unused dictionary word
in the string table did not fail it, because Rollup dropped the unused property. Re-testing with
a rendered string made it fail correctly. It now also decodes `\uXXXX` escapes, so a minifier
configured to emit ASCII cannot hide the words from a substring search.
**`jsdom` broke the phase-4 fixture test.** Making it the global test environment rewrote
`import.meta.url` to an `http:` URL, so the cross-language fixture reader could not resolve its
path. The DOM is now opted into per file by the two suites that need it.
**`svelte-check` found a required prop that no caller passed.** `DifficultyPicker` declared
`onselect` as required while the home screen only binds `value`. The prop is optional now.
### Guards added after review
| Guard | What it would catch |
|---|---|
| `tests/bot-session.test.js` | A rematch sending two requests, or a result scored twice |
| `tests/error-codes.test.js` | A server error code with no Vietnamese message, read from the Go source that emits them; it also fails on copy for a code the server cannot send |
| `TestStaticCacheHeaders` | The shell becoming cacheable across a deploy |
| Build freshness in `tests/bundle.test.js` | The wordlist check reading a stale bundle |
| Handshake ordering in `tests/ws-client.test.js` | `Hello` losing its place ahead of the open status |
The error-code guard and the wordlist guard were both negative-tested by breaking what they
protect and watching them fail for the right reason. The bundle budget was 1.5 MB against a
188 KB bundle, which caught nothing; it is 400 KB now.
### Deviations from the plan
- Scaffolded by writing the config files directly rather than `npx sv create`, which is
interactive. ESLint and Prettier were not added; `svelte-check` is wired into `make test-web`
instead.
- Tests live in `web/tests/`, following the layout phase 4 established, not beside their
sources.
- `npm test` now runs `vite build` first. The bundle check reads built output, and a stale
build would let it pass over code that no longer exists. This also means the proto workflow's
JavaScript step builds the frontend, which needs no dictionary and stays fast.
### Not verified
Five criteria need a real browser and are left unchecked: one-handed mobile use, Telex
diacritic entry, the absence of a theme flash, reconnect after the server is killed mid-game,
and the rematch button. The logic behind the last three is unit-tested — the pre-paint theme
script, the jittered backoff schedule, and the game-request latch — but none has been watched
happen. The rematch path is worth naming separately: it is where review found the blocking
defect, and the fix is verified by unit tests over the latch rather than by playing a game.
The countdown criterion is no longer known to be violated on the opening turn, since the clock
probe now goes out with the handshake, but the ~200ms figure has not been measured.
A resumed session shows the opening word and the last move rather than the whole chain. That
is the server's replay contract, which rebuilds the position from the engine rather than
replaying a recorded stream; the client renders what it is sent.
@@ -1,277 +0,0 @@
---
title: "Phase 7: Online 1v1 and Release"
status: done
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**
- [x] Create a room → shareable 6-character code and a copyable invite link
- [x] Join by code, or by opening an invite link with the code prefilled
- [x] Waiting room until the opponent arrives; leaving cleans the room up
- [x] Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only)
- [x] Live opponent state: their turn, their timer, their disconnect and reconnect
- [x] Resign, and rematch in the same room after a game ends
- [x] Clear Vietnamese error states: room not found, room full — a room whose game has started is full, so there is no third state
**Non-functional**
- [x] E2E coverage of both modes with two real browser contexts
- [ ] CI: Go tests + race, JS tests, `buf lint`/`breaking`, generated-code drift check, builds
- [x] Deployable artifact: container image with the binary, the static frontend, and `noitu.db`
- [x] 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
- [x] Two independent browser contexts play a full game via a shared room code; two physical machines untested
- [x] Invite link opens straight into the room
- [x] Both join errors the protocol has show correct Vietnamese messages, plus a malformed code caught before it is sent
- [x] Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win
- [x] Rematch restarts in the same room with a new opening word
- [x] Resign ends the game immediately with the right winner
- [x] 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
- [x] `docker run` serves a playable game; the image contains `data/LICENSE`, `data/ATTRIBUTION.md` and `NOTICE` but no upstream file — verified against a fixture-dictionary build, not an upstream one
- [x] 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 |
## Phase 7 Outcome (2026-09-05)
Online 1v1 is playable end to end, the whole game is covered by a browser suite, and the
result ships as one 24 MB container image.
### What was verified, and how
| Claim | Evidence |
|---|---|
| Two players join by code and alternate turns | Two browser contexts against the real binary; each side sees the other's sanitized nickname and the other's words |
| An invite link opens straight into the room | The second context only opens a URL — no code typed, no button pressed |
| A rematch restarts the same room | One acceptance shows the other player a prompt; the second starts a new game with a fresh opening word and the room code unchanged |
| A disconnect is announced and survivable | Taking a player's page away raises the opponent's banner; returning inside the window restores the same position and clears it |
| An opponent who never returns forfeits | The grace window expires and the win is awarded, with the reason shown |
| The image runs and carries its obligations | `docker run` serves the app and a deep link; the image contains `data/LICENSE`, `data/ATTRIBUTION.md` and `NOTICE`, and no upstream database |
| The difficulty ladder is real | The simulation suite from phase 3 reports Hard beating Easy 94 games to 6, Medium beating Easy 93 to 7, and Hard beating Medium 78 to 22 |
21 browser tests, 139 JavaScript unit tests, and the Go suite under `-race` all pass.
### Deliberate design points
**The room now outlives its game, but only when there is someone to ask.** A finished game
opens a rematch offer when both seats still hold live connections. A bot room closes exactly
as before, because there is nothing to negotiate with a bot and keeping it alive would leak a
goroutine and an engine per finished game.
**There is no decline message.** Leaving is the decline, and the server already learns about
that from the socket closing. One message and one timeout cover every way a rematch does not
happen, which is a smaller protocol and one less state to get wrong.
**`turn_seq` no longer restarts at one.** A rematch reuses the same connections, so a
submission still in flight from the previous game could otherwise match a turn in the new one
and be applied to it.
**The fixture dictionary goes through the real builder.** `build-dictionary --words` reads a
checked-in list and runs it through the same filter, alias and write path production uses, so
a test database cannot drift into being shaped differently from what the server loads. Its
graph has exactly one syllable productive enough to open on, which is what makes a scripted
game deterministic without pinning the bot's replies.
### Defects found by building the browser suite
**A player who refreshed mid-game could not get back in.** The online screen waited for the
player to ask for a room before connecting — correct for someone who has just arrived and is
still typing their name, wrong for a tab that already holds a session. It now reconnects
immediately when a resume token is present.
**The opponent's disconnect banner never cleared.** The server restored the seat but told
nobody, so the other player watched "đối thủ mất kết nối" for someone already playing again.
A resume now announces itself to the opponent.
**A dead socket was invisible to the client.** Nothing noticed a connection that failed
without closing, so the page kept showing a live connection and a running countdown over a
socket nothing could reach. The client now treats silence longer than three ping intervals as
a dead socket and reconnects.
**The nickname typed on the online screen never reached the server.** The socket opened on
arrival and `Hello` carries the name once, so both players were introduced under whatever was
stored before they got there. Connecting when the player actually asks for a room fixed it.
### Two tools that did not work, and why
**Offline emulation does not cut a loopback socket.** A test that "went offline" kept playing
happily against the local server. Routing the WebSocket cuts it for real.
**A routed socket's close does not reliably reach the server.** The page observes it, but the
grace window never starts. That makes socket routing the right tool for what the client does
about a dead connection and the wrong one for what the server does about a missing player; the
latter tests take the page away instead. Both limitations are recorded in `e2e/socket-cut.js`
so the next person does not rediscover them.
### Deviations from the plan
- Playwright lives under `web/` rather than the repository root, so there is still one npm
package rather than two.
- There are two join errors, not three: a room whose game has started is full, and the server
has no separate code for it. A malformed code is caught in the client before it is sent.
- CI is split across two workflows. `proto.yml` keeps the wire contract, `ci.yml` owns the
tests and the image, and the duplicated test steps were removed from the former.
- The image is built on every push against the fixture word list, not only on release. An
image built only at release time is an image that breaks at release time, and the build
argument that makes this cheap already existed for local testing.
### Defects found by review
Review reproduced three defects, two of them consequences of the new state this phase
introduced: a room that outlives its game.
**A game that ended on the turn clock never offered a rematch, and the button was still
there.** The offer was opened from the message arm of the room loop, so the most common
natural ending — running out of time — closed the room with nothing to accept. The player
pressed "chơi lại" and got an error. The end-of-game decision now sits after the whole select,
so every way a game can end reaches it, and the online game-over panel no longer shows a
rematch button at all: the prompt appears only while an offer is actually open, so it is the
only thing that can ask.
**A second connection presenting the same resume token disconnected the player who was still
there.** `resumeFrom` retired the old connection as soon as the room had *accepted the
message*, but the room decides asynchronously and refuses a resume into a finished game — which
is exactly the state during a rematch offer. A duplicated tab therefore ended the room. The
room now retires the old connection only once it has agreed to the swap. Before this phase the
window did not exist, so a refused resume was unreachable.
**Rooms leaked when one connection created several.** `attach` overwrote the session's room
pointer and nothing told the old room, which then parked in `select` forever holding a
goroutine and a room code. Rate limiting bounds the rate, not the total, and connections are
free, so this was unbounded on a public endpoint. `attach` now releases the room it is leaving.
This one predates the phase; it is fixed here because this is the phase that puts the server on
the internet.
Also fixed: `RequestRematch` had no rate limit despite being the only client message that fans
out to both players, so a burst could fill the opponent's outbox until the server closed their
session. A stale disconnect notice was being treated as a rematch decline, which needed
`handleDisconnect` to stop conflating "this notice applied" with "a game is still running".
The client's liveness check could fire on a throttled background tab and close a healthy
socket, so it now ignores a tick that was itself late. And a stale resume token opened the
lobby with an error the player did nothing to cause.
Each of the three defects has a regression test, and the two that were reachable through the
room loop were negative-tested by reverting the fix and watching them fail.
### Not verified
CI has not run on GitHub. Every step passes locally, including the image build and its licence
assertions, but the workflows themselves are unexercised.
The image was built from the fixture word list, not the 179 MB upstream release. The
downloading branch of the `Dockerfile` is the same shape, pinned by the same checksum the
`Makefile` uses, but it has not been run here.
Two players on two physical machines have not played. Two independent browser contexts have,
which exercises everything except the network between them.
-381
View File
@@ -1,381 +0,0 @@
---
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: complete
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) | Complete | — |
| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Complete | 1 |
| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Complete | 2 |
| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Complete | 1 |
| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Complete | 3, 4 |
| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Complete | 4, 5 |
| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Complete | 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
- [x] `server/cmd/build-dictionary` reproducibly turns the upstream `dictionary.db` into `data/noitu.db`; entry count and ≥2-syllable purity asserted
- [x] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent
- [x] 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ý`
- [x] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable
- [x] One `.proto` generates working Go and JS clients; no hand-written message types
- [x] Vs-bot playable end to end at all 3 difficulties; Hard bot wins measurably more than Easy over 100 simulated games
- [x] 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
- [x] Vietnamese UI throughout; nickname and high score persist, checked in a real browser. The dark-mode toggle persists by the same mechanism but has only been unit-tested
- [x] `go build` produces one binary; `web` builds to static assets served by that binary
- [x] No wordlist reachable from the client bundle (verified by inspecting the built assets)
- [ ] CI runs green without ever downloading the 179 MB upstream DB — the workflows are written and every step passes locally, but they have not run on GitHub
## 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.
## Phase 4 Outcome (2026-09-04)
The wire contract is fixed and generated into both languages. Detail in
[`phase-04`](./phase-04-protobuf-contract-and-codegen.md#phase-4-outcome-2026-09-04).
Enum values carry buf's mandated enum-name prefix (`REJECT_REASON_TOO_FEW_SYLLABLES`, not
`TOO_FEW_SYLLABLES`), so `buf lint` passes with no carve-out; `protobuf-es` strips the prefix,
leaving the JS side reading `RejectReason.WRONG_LINK`. Two fields were added that the plan
sketch lacked: `PlayedWord.typed`, because the engine keeps the player's raw input alongside
the canonical spelling and the UI has to show a correction happened, and
`REJECT_REASON_GAME_OVER`, because `game.ReasonGameOver` exists and the exhaustiveness test
correctly refused a wire contract without it.
`web/` now exists as a bare npm package — protobuf and Vitest only — because phase 4's own
JS codegen and cross-language test need somewhere to live. Phase 6 layers SvelteKit on top.
Both suites read the same 17 binary fixtures in `proto/testdata/`, emitted by the Go tests.
Review caught four guards that were reporting green while protecting nothing — a `buf
breaking` baseline that cannot resolve on a PR checkout, a sync check blind to untracked
files, and two tests whose oracles could drift with the code they checked. All four were
fixed and then negative-tested by deliberately breaking each one.
## Phase 5 Outcome (2026-09-05)
The transport layer is in: rooms, turn timers, the bot as a virtual player, reconnect, and
a single binary that serves both the API and the frontend. Detail in
[`phase-05`](./phase-05-go-websocket-server-and-rooms.md#phase-5-outcome-2026-09-05).
The concurrency contract holds structurally rather than by convention: the room goroutine
starts before anyone is seated and seating itself is a message, so reading `run()` is a
complete proof that one goroutine owns each engine. The bot searches a frozen board rather
than the live engine, and reads carry no deadline — liveness is ping-based, because a read
timeout cannot distinguish a healthy player idling in the lobby from a dead socket.
Seven defects were found and fixed: three by writing the tests, three more by review, and
a seventh while fixing those. The serious one was authorization — the hub bound a joiner
to a seat before the room decided whether to seat them, so anyone holding a room code
could resign or play on a seated player's behalf. Since the room code is the only
credential online 1v1 has, that was a phase-7 release blocker caught a phase early.
## Phase 6 Outcome (2026-09-05)
The vs-bot UI is built and served by the Go binary. Detail in
[`phase-06`](./phase-06-sveltekit-frontend.md#phase-6-outcome-2026-09-05).
The store is a reducer over `ServerMessage` and computes nothing: validity, turn order and the
result are read from the wire, which is what lets one screen serve both the bot and, in phase
7, online play. The word field is deliberately uncontrolled, because a Telex input method
composes a diacritic over several keystrokes and writing the value back cancels it. The
countdown is drawn against the server's clock and settles 300ms early, so the ring never claims
more time than the server allows.
Three defects were found by building and five more by review. The serious one was that the game
lifecycle was inferred from the game model rather than owned: the screen asked for a bot game
whenever the board was idle, and clearing the board for a rematch is exactly that condition, so
every rematch started two server-side rooms that then destroyed each other. A request is stored
intent now, with tests over the wiring whose absence let it through. Review also caught a word
silently dropped when typed during a reconnect, a protocol bump that would have put every open
tab into a reconnect loop, an opening turn counted against the device clock, and an
`index.html` cacheable across a deploy.
Four criteria need a real browser and stay open: one-handed mobile use, Telex diacritic entry,
the absence of a theme flash, and reconnect after the server is killed mid-game.
## Phase 7 Outcome (2026-09-05)
Online 1v1 is playable end to end and the game ships as one 24 MB container image. Detail in
[`phase-07`](./phase-07-online-1v1-and-release.md#phase-7-outcome-2026-09-05).
The rematch needed the one thing phase 5 deliberately did not do: let a room outlive its game.
It now does, but only while both seats hold live connections, so a bot room still closes the
moment its game ends. There is no decline message — leaving is the decline, which the server
already learns from the socket closing.
Building the browser suite found four defects the unit tests could not: a player who refreshed
mid-game could not rejoin, a restored opponent never cleared the other player's disconnect
banner, a socket that failed without closing was invisible to the client, and a nickname typed
on the online screen never reached the server because the handshake had already gone.
Two testing tools turned out not to work here and are documented where the next person will
look: offline emulation does not cut a loopback socket, and a routed socket's close does not
reliably reach the server, so the grace window never starts.
Review then reproduced three more. Two follow from the new state this phase introduced — a room
that outlives its game — and one predates it: a game ending on the turn clock never offered a
rematch although the button was there, a second connection presenting the same resume token
disconnected the player who had not left, and rooms leaked whenever one connection created
several. All three now have regression tests, and the two reachable through the room loop were
negative-tested by reverting the fix.
## 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.
4. Should the turn clock pause while a seat is inside its reconnect grace window? As
built the grace window (30s) is longer than the turn limit (20s), so a player who
drops on their own turn always loses on time before the window closes — the grace is
unreachable for the player most likely to need it. Their reconnect is now told
`game_already_over` rather than left waiting, but the underlying policy is unresolved.
5. Should bot rooms be registered in the joinable room namespace at all? They have no code
to share, and excluding them removes a whole class of stranger interference for free.
6. Is closing the connection the right response to a full outbox mid-game? 32 queued
frames is not much on a lossy mobile link, and the opponent currently sees that player
as having left.
@@ -1,70 +0,0 @@
# Room lobby with owner/guest roles and ready-up
**Status:** delivered
## Outcome
An online room is a persistent lobby rather than a wrapper around one game. The
first occupant is the owner, the second is the guest. The guest readies; the
owner starts once they are; a finished game returns both to the lobby, where the
guest must ready again. Players can leave, the owner can kick an unready guest,
and an owner who leaves hands the room to whoever is left.
## Decisions taken
- **The owner has no ready flag.** Pressing Start *is* their readiness, so only
the guest can be ready, blocked from leaving, or kicked.
- **Room lifetime:** closes when the last seat empties, or after 10 minutes in
the lobby with no game started. A running game is bounded by the turn clock.
- **The rematch handshake is retired.** `RequestRematch`/`RematchState` stop
being sent and their proto tags are reserved; the lobby is the only place a
next game is negotiated.
- **A kick only frees the seat.** The kicked player may rejoin with the code.
## Constraints
- Proto changes are additive; retired tags go to `reserved`, never reused.
- The engine stays two-player; `seats [2]*seat` stays the room's shape.
- Readiness, roles, start and kick are server state. The client renders one
snapshot message and derives nothing.
- Only the room goroutine touches room state; new actions arrive as inputs.
- Every lobby action authorizes against the seat, not the claimed id: a room
code is a shared secret by design.
- Vietnamese copy stays in `web/src/lib/i18n/vi.js`; error codes stay UI keys.
## Non-goals
Bot games (start immediately, no readiness), spectators, more than two seats,
kick bans, accounts, and resuming into a room whose game already finished.
## Acceptance criteria
1. A guest who joins sees a lobby, not a board. No engine exists until Start.
2. Start is refused unless the guest is seated, connected and ready.
3. Game over returns both to the lobby with the guest unreadied; a second game
needs a fresh ready.
4. Leave while ready is refused; leave while unready frees the seat and the room
survives.
5. Kicking a ready guest is refused; kicking an unready one frees the seat and
tells them why.
6. An owner who leaves, or drops for good, promotes the other player, who is
unreadied and can start once someone joins.
7. The last player out closes the room; an idle lobby closes on the TTL.
8. A reconnect lands back in the lobby with roles and readiness intact.
9. Start racing an unready or a leave, and a kick racing a ready, resolve to an
error for the loser and never a half-started game.
## Phases
1. **Wire contract.** `SetReady`, `StartGame`, `KickPlayer`, `LeaveRoom`,
`RoomState`; reserve the rematch tags; regenerate both trees; extend the
cross-language fixtures.
2. **Room lifecycle.** Owner/guest roles, ready flag, start, kick, leave,
promotion, idle TTL, resume into a lobby, retire the rematch path. Route and
rate-limit the new messages in `session.go`.
3. **Client.** A `lobby` phase in the game store driven by `RoomState`, a Lobby
component, retire `RematchPrompt`, copy, and the `/online` route wiring.
4. **Tests.** Room tests for each acceptance criterion, store tests for the new
phase, e2e for join → ready → start → play → back to lobby, leave, kick and
promotion.
5. **Docs.** The online-play section of the README.
@@ -1,118 +0,0 @@
---
phase: 1
title: "Wire contract"
status: completed
priority: P1
effort: "1h"
dependencies: []
---
# Phase 1: Wire contract
## Overview
Adds the three messages chat needs to `proto/noitu/v1/game.proto`, regenerates
both trees, and extends the cross-language fixtures so the Go and JavaScript
clients are checked against one artifact.
## Requirements
- Functional: a client can send text; the server can deliver one message to
each recipient rendered from their side, and replay a bounded history.
- Non-functional: additive only. The current maxima are `ClientMessage` 12
(`leave_room`, with 8 reserved) and `ServerMessage` 12 (`room_state`, with
2, 3 and 11 reserved), so the next free tags are 13 on the client oneof and
13 and 14 on the server oneof.
## Architecture
```proto
// SendChat is one line of text from a seated player to the other.
message SendChat {
string text = 1;
}
// ChatMessage is one line as one recipient sees it. Rendered per recipient
// like every other room message: from_me is the only field that differs, and
// it is what lets the client style its own words without matching names.
message ChatMessage {
bool from_me = 1;
// Server-sanitized, as everywhere a name is shown. Empty when the author
// has left the room: the seat they spoke from may be somebody else's now,
// and a name outlives neither. The client labels an empty author itself.
string author = 2;
string text = 3;
// Server clock. The client renders it; it never orders by its own clock.
int64 sent_unix_ms = 4;
}
// ChatHistory is the whole panel, oldest first, sent when a player is seated
// in a room or resumes into one. A snapshot rather than a replayed stream, for
// the same reason RoomState is one: a client that missed frames is correct
// again from the next one instead of having to catch up on events.
//
// Scoped to the recipient: it carries only what was said while they held their
// seat, which is why it is built per seat rather than broadcast.
message ChatHistory {
repeated ChatMessage messages = 1;
}
```
Wired into the oneofs as `send_chat = 13` (client) and `chat_message = 13`,
`chat_history = 14` (server).
`ProtocolVersion` does **not** change, and the honest reason is not that an
old client is unaffected — it is pushed `ChatMessage` frames it never asked
for, because delivery is unconditional and there is no capability negotiation.
It drops them silently: the generated union yields `case: undefined` and the
store's `apply` switch has no `default` arm. The degradation is one-way chat
for the length of a deploy, and it is accepted rather than unnoticed. Phase 3
adds the `default` arm so the next contract addition is at least visible in a
console. If one-way chat is ever *not* acceptable, bumping `ProtocolVersion` is
the only mechanism the codebase has, and it forces a stale tab to reload.
## Related Code Files
- Modify: `proto/noitu/v1/game.proto`
- Modify (generated, do not hand-edit): `server/gen/noitu/v1/game.pb.go`,
`web/src/lib/proto/noitu/v1/game_pb.js`, `web/src/lib/proto/noitu/v1/game_pb.d.ts`
- Modify: `server/internal/wsapi/wire_test.go` — add `client_send_chat`,
`server_chat_message`, `server_chat_history` variants
- Create (generated): `proto/testdata/client_send_chat.bin`,
`proto/testdata/server_chat_message.bin`, `proto/testdata/server_chat_history.bin`
- Modify: `web/tests/game-wire.test.js`
## Implementation Steps
1. Add the three messages and their oneof arms to `game.proto`.
2. Lint and regenerate: `go run github.com/bufbuild/buf/cmd/buf@v1.58.0 lint`
then `... generate` from the repo root (no global `buf` needed).
3. Add the three variants to `wire_test.go`. Give them Vietnamese text with
diacritics and a real millisecond timestamp — the JavaScript runtime
surfaces `int64` as a bigint, and a zero value would skip that. Give
`ChatHistory` two messages: one from each side, and one with an empty
`author`, so the repeated field, both `from_me` values and the departed-author
case are all exercised.
4. Regenerate fixtures: `cd server && go test ./internal/wsapi -update`.
5. Assert the new fixture in `web/tests/game-wire.test.js`, including that
`sentUnixMs` arrives as a bigint and that an empty `author` survives the
round trip.
## Success Criteria
- [x] `buf lint` passes and both generated trees are current.
- [x] `TestVariantTablesCoverEveryOneofArm` passes, so every new arm is covered.
- [x] `go test ./internal/wsapi` and `npm test` both decode the new fixtures.
- [x] `git diff` on the generated trees shows only the new messages.
## Risk Assessment
Low. The one thing to get wrong is a tag collision with a reserved number;
`buf lint` catches it. If a later phase finds `ChatHistory` unnecessary, the
tag is reserved rather than reused — the cost of carrying it is one unused
message, not a migration.
The `author`-empty convention is load-bearing for Phase 2: if Phase 2 stores
and sends a departed player's name instead, two different people can appear
under one attribution. Signal: the Phase 4 vacate tests. Response: the
convention is stated here and tested there; keep them in step.
@@ -1,241 +0,0 @@
---
phase: 2
title: "Room chat"
status: completed
priority: P1
effort: "4h"
dependencies: [1]
---
# Phase 2: Room chat
## Overview
The server side: authorize the sender's seat, sanitize and cap the text,
rate-limit the sender without endangering the recipient, keep the last 20
messages per room, deliver each one rendered per recipient, and replay to a
seated player what was said while they held that seat.
## Requirements
- Functional: a seated player in a non-bot room can send text to the other
seat; both see it. A seating and a resume replay the recipient's own slice of
the history. History outlives a game and dies with the room.
- Non-functional: bounded memory per room; text sanitized before it is stored;
chat rate-limited on its own budget so talking never costs a move, and sized
so that talking cannot close the other player's session either; chat is not
room activity, so the idle timer still bounds the room.
## Architecture
**Sanitizer.** `sanitizeNickname` is close to the filter chat needs — NFC,
control and format characters dropped, whitespace collapsed, rune cap — but two
things separate them. Its cap and its empty-input fallback are nickname policy,
and its character filter admits combining marks: `unicode.IsPrint` counts marks
as printable and NFC does not discard uncomposable ones. At 20 runes that is
cosmetic; at 200 it is a glyph cluster hundreds of lines tall that escapes the
panel and covers the board, and it would sit in the history being replayed.
Extract the shared body into `sanitizeText(raw string, maxRunes, maxMarks int)
string` in `nickname.go`, returning `""` when nothing survives:
- NFC, then the existing control/format/non-print filter;
- **new:** drop `unicode.Mn`/`unicode.Me` runes beyond `maxMarks` consecutive
after a base rune (2 is enough for any Vietnamese cluster);
- collapse whitespace with `strings.Fields`, then the rune cap.
`sanitizeNickname` becomes that call plus the `defaultNickname` fallback and the
`denylist` hook. Its existing tests must pass unchanged — Vietnamese names carry
at most two marks per base rune, so the mark budget is invisible to them.
Collapsing whitespace also means a chat line cannot contain a newline, so no
message can be made to occupy a column of its own in the panel.
**Constants** (`room.go`, beside the other room constants):
```go
// chatHistoryLimit is how many messages a room keeps, and the same window the
// client holds. Enough to catch up on after a reload, few enough that a room
// that lives all day cannot grow.
const chatHistoryLimit = 20
// maxChatRunes caps one message. Runes, not bytes: Vietnamese is multi-byte
// and a byte cap would cut it far shorter than a Latin one.
const maxChatRunes = 200
// maxChatMarks is how many combining marks may follow one base rune. Two
// covers every Vietnamese cluster; more is a stacking attack, not a word.
const maxChatMarks = 2
```
**Rate limit** (`session.go`): a third bucket beside `submitLimiter` and
`roomLimiter` — `chatLimiter = newBucket(chatsPerSecond, chatBurst, ...)` with
`chatsPerSecond = 1`, `chatBurst = 3`. Its own budget so talking never costs a
move, and sized against the recipient rather than the sender: `outboxCap` is 32
and a single `Write` can stall for `writeTimeout` (10s), so the existing 5/s of
lobby and move traffic plus 1/s of chat stays comfortably inside the window a
stalled reader can drain.
**Delivery is best-effort.** `session.send` closes a session whose outbox is
full — correct for game frames, wrong for chat, because it hands one player a
way to disconnect the other into a grace-window abandonment loss. Add
`session.trySend(m)` that takes the same encode path but, on a full outbox,
logs and returns `false` instead of calling `close()`. Chat frames — and only
chat frames — go through it. A dropped chat frame is recoverable: the next
resume replays the history.
**Input.** `chatInput{sess *session, player game.PlayerID, text string}` on the
room's existing channel. In `dispatch`, gate on `chatLimiter` and `currentRoom()`
exactly as `toRoom` does, and answer a refused enqueue: `if !r.send(chatInput{…})
{ s.send(errorMsg("busy")) }`, matching `handleSubmit`. Nothing about the room's
own state is read on the session goroutine — `r.strategy` in particular is
room-goroutine-only, so the bot-room refusal belongs in the handler, not here.
**`handleChat` opens with authorization, like every other room mutation:**
```go
func (r *room) handleChat(m chatInput) {
// The seat, not the claimed id. A connection the room has already
// retired — kicked, or replaced by a reconnect — can still have a frame
// in flight, and its seat may belong to somebody else by the time the
// room drains it.
if !r.occupies(m.sess, m.player) {
m.sess.send(errorMsg("not_your_seat"))
return
}
if r.strategy != nil {
m.sess.send(errorMsg("not_in_a_room"))
return
}
…
}
```
**Storage.** On the room:
```go
// chat is the room's recent conversation, oldest first, capped at
// chatHistoryLimit. It outlives each game the way the room does.
chat []chatEntry
// chatSeq counts every message the room has accepted, ever. A seat records
// the value it was filled at, which is what scopes a replay to what that
// player was actually present for.
chatSeq uint64
type chatEntry struct {
seq uint64
// author and name are cleared together when the seat is vacated: the
// words stay, the attribution does not. Keeping the name would let the
// next person to request that nickname inherit a stranger's messages,
// since distinguish() only compares against the seat that is occupied.
author game.PlayerID
name string
text string
at time.Time
}
```
On `seat`: `chatFrom uint64`, set to `r.chatSeq` when the seat is filled
(`handleCreate`, `handleJoin`, `handleStartBot` for symmetry). A resume does not
touch it — the seat persists, so the player gets their conversation back.
`vacate` clears both `author` and `name` on that seat's entries. `beginGame`
does **not** touch the history: the room's conversation is not the game's.
**Idle timer.** `resetIdleTimer()` runs at the tail of every run-loop iteration,
so a chat message would restart the ten-minute lobby window and a room could be
held open forever by typing into it once every nine minutes. Chat is explicitly
not activity: track it per iteration —
```go
case chatInput:
r.handleChat(m)
// Deliberately no resetTurnTimer and no idle reset: talking is not
// playing, and the room's ten-minute bound has to survive a
// conversation.
idleActivity = false
```
— and guard the tail call with it. Skipping the reset leaves the running timer
alone, which is what "the clock keeps ticking" means.
**Replay.** `chatHistoryFor(s *seat)` sends one `ChatHistory` holding the
entries with `seq >= s.chatFrom`, oldest first. Called from `handleCreate`,
`handleJoin`, and `handleResume` — **before** `handleResume`'s
`if r.inLobby() { return }`, or a lobby refresh (the commonest resume) never
gets one. `handleCreate` sends an empty history on purpose: it is what
overwrites a stale array in a client that has been in another room.
The history therefore *precedes* that input's `RoomState`, which is broadcast
from the run-loop tail after the handler returns. That is fine and the client
must not depend on the order: `chatHistory` replaces wholesale and reads no
`RoomState`-owned field. Do not claim the opposite anywhere.
## Related Code Files
- Modify: `server/internal/wsapi/nickname.go` — extract `sanitizeText` with the
mark budget
- Modify: `server/internal/wsapi/room.go` — constants, `chatEntry`, `chat`,
`chatSeq`, `seat.chatFrom`, `handleChat`, `chatHistoryFor`, `vacate`
attribution clearing, the `chatInput` arm and the idle-activity guard in `run`
- Modify: `server/internal/wsapi/session.go` — `chatLimiter`, `trySend`, the
`ClientMessage_SendChat` arm in `dispatch`
- Modify: `server/internal/wsapi/codec.go` — builders if the room's existing
inline style does not fit
## Implementation Steps
1. Extract `sanitizeText(raw, maxRunes, maxMarks)` with the mark budget and
re-express `sanitizeNickname` on top of it. Run the existing nickname tests —
they must not change.
2. Add the constants, `chatEntry`, `chat`, `chatSeq` and `seat.chatFrom`; set
`chatFrom` at every seating site.
3. Add `chatLimiter` and `trySend`; add the `dispatch` arm with the `busy`
answer.
4. Implement `handleChat`: `occupies`, bot-room refusal, sanitize, drop empty,
append with the cap and a rising `chatSeq`, broadcast per recipient via
`trySend`.
5. Add the `chatInput` arm to `run` and the idle-activity guard around
`resetIdleTimer()`.
6. Clear `author` and `name` in `vacate`; leave `beginGame` alone.
7. Add `chatHistoryFor` and call it from `handleCreate`, `handleJoin`, and
`handleResume` before the lobby early return.
## Success Criteria
- [x] Two seated players exchange messages in the lobby and mid-game.
- [x] `from_me` is true only for the sender's own copy.
- [x] The 21st message pushes the 1st out.
- [x] A joiner's replay holds nothing said before they were seated; a resuming
player's holds everything they were present for.
- [x] A creator receives an empty history, so a stale client array is replaced.
- [x] A lobby resume replays, not only a mid-game one.
- [x] Text over the cap is truncated at a rune boundary; a 199-mark stack is
truncated; control and format characters never reach the other player.
- [x] A burst is refused with `too_fast` and leaves the move budget intact.
- [x] Sustained chat against a stalled reader drops frames rather than closing
that session.
- [x] A dropped `chatInput` answers `busy`.
- [x] An unseated connection, a connection the room has replaced, and a bot
room are all refused.
- [x] A room with chat traffic and no game still closes on the idle timer.
- [x] After a seat is vacated, that seat's messages carry no author and no
name.
## Risk Assessment
The authorization and the attribution rules are the two that bite. Skip
`occupies` and a retired connection speaks as a seat somebody else now holds;
keep the departed player's name and a newcomer can request that nickname and
inherit their messages, because `distinguish` only compares against the seated
opponent. Signal: the Phase 4 replaced-connection and vacate tests. Response:
both are a few lines at a single chokepoint — `handleChat`'s first statement
and `vacate`'s loop — so fix in place.
The mark budget is a judgement call: two marks per base rune covers Vietnamese,
but it is a rule about text, not a standard. Signal: a legitimate message losing
a mark. Response: raise `maxChatMarks`; do not remove it.
Memory is bounded by construction (20 entries × 200 runes per room). Room
*lifetime* is bounded by the idle timer only because chat is excluded from it —
that exclusion is the load-bearing part, not the byte count.
@@ -1,177 +0,0 @@
---
phase: 3
title: "Client chat panel"
status: completed
priority: P1
effort: "4h"
dependencies: [1, 2]
---
# Phase 3: Client chat panel
## Overview
The panel the players actually use: a message list and an input, driven by the
store, rendered in the lobby and on the board. Expanded in the lobby, collapsed
behind an unread count during a game so it does not crowd a phone screen.
Depends on Phase 2 as well as Phase 1: every behavioural criterion below needs
a server that accepts `SendChat` and emits `ChatMessage`/`ChatHistory`.
## Requirements
- Functional: send a message; see both sides' messages in order with the
author's name, or a "left the room" label where the author is empty; a
replayed history replaces the list wholesale; an unread count while
collapsed; every server refusal visible, including in the lobby.
- Non-functional: text rendered as text, never markup; Enter must not send
mid-composition and the field's value must never be written back; the list
must not steal the page's scroll; the list must not grow without bound.
## Architecture
**Store** (`game.svelte.js`). One new field and two new arms:
```js
/**
* The room's conversation, oldest first, capped at the same window the server
* keeps so the two can never disagree about it.
*
* @type {{ fromMe: boolean, author: string, text: string, atMs: number }[]}
*/
chat: [],
```
- `chatMessage` → push one entry (`atMs: Number(value.sentUnixMs)` — the field
is an `int64` and arrives as a bigint, exactly like `deadlineUnixMs`), then
trim the front past `CHAT_WINDOW` (20, mirroring `chatHistoryLimit`).
- `chatHistory` → replace the array wholesale. A snapshot replaces; it never
merges. It also resets the unread count: the player is being resynchronised,
not shown new mail.
- Add a `default:` arm to the `switch` that logs an unhandled payload case, so
the next protocol addition degrades visibly instead of silently (Phase 1).
**Clearing is the subtle part.** `chat` joins the `kept` set, because a game
starting must not wipe the conversation — but `kept` means "survives
`reset()`", and the online route calls `reset()` on *mount and unmount* as well
(`online/+page.svelte`), so `kept` alone would carry room A's conversation into
room B. Two independent fixes, both cheap:
1. A `clearChat()` on the store, called in the online route's mount and
unmount effect alongside `reset()` — leaving the screen is leaving the room.
2. Phase 2 sends a `ChatHistory` on every seating including `handleCreate`, so
a stale array is overwritten by the server's truth even if the route forgets.
`leave()` clears it too, as it clears everything.
**`ChatPanel.svelte`** — props `{ collapsible?: boolean, onsend: (text: string) => void }`.
- Message list: `<ol>` with `overflow-y: auto` in its own scroll container,
`class:mine={entry.fromMe}`, the author's name shown only on the other side's
messages, `t.chatAuthorLeft` where `author` is empty, and time as `HH:mm` from
`atMs`.
- Scroll: oldest-first, so it scrolls to the **bottom** on a new message, and
only when already near it so it cannot yank a player out of scrollback. Write
this explicitly — `ChainHistory.svelte` is *not* the precedent: it renders
newest-first and scrolls unconditionally to `top: 0`.
- Input: **one-way mirroring only.** `oninput={(e) => (draft = e.currentTarget.value)}`
and never write `draft` back to the element — `WordInput.svelte` is
deliberately uncontrolled because writing the value back cancels Telex
composition and mangles the accent, and a `bind:value` here would reintroduce
exactly that. Keep its `oncompositionstart`/`oncompositionend` submit guard.
- Send is enabled when the draft is non-blank after trimming **and** stripping
zero-width and format characters — the same emptiness the server tests for
after sanitizing, which is what makes the server's silent drop unreachable
rather than merely unlikely.
- Length: check the rune count (`[...draft].length`) in the send handler, not
`maxlength` — `maxlength` counts UTF-16 code units, not runes, and interferes
with composition in some engines. The server truncates anyway; this is only
so the player is not surprised.
- Interpolated with `{entry.text}`, never `{@html}`.
- `collapsible`: a header button toggling the list, showing an unread count.
Unread is client-only state — messages that arrived while collapsed, reset on
expand and on a history replace. Per viewer, so nothing about it goes on the
wire.
**Error surface.** `Lobby.svelte` renders no `game.state.error` today, and the
online route's error paragraph exists only in the pre-room branch — so a
`too_fast`, `busy` or `not_your_seat` refusal in the lobby is invisible. Render
the store's error inside `ChatPanel` (dismissible, `role="alert"`), which covers
every phase the panel appears in and incidentally gives the lobby's own
refusals — `must_unready_first`, `player_is_ready`, `not_the_owner` — somewhere
to land.
**Placement** (`online/+page.svelte`):
- `phase === 'lobby'` → `<ChatPanel onsend={say} />` under `<Lobby />`.
- `phase === 'playing' | 'over'` → `<ChatPanel collapsible onsend={say} />`
passed to `GameBoard` as a `chat` snippet, rendered below the chain.
`GameBoard` already takes optional snippets (`banner`, `gameOver`), so the
bot route passes none and gets no panel — which is how the non-goal stays a
non-goal rather than a runtime check.
**Copy** (`vi.js`): `chatTitle: 'Trò chuyện'`, `chatPlaceholder: 'Nhắn tin…'`,
`chatEmpty: 'Chưa có tin nhắn nào.'`, `chatUnread: '{n} tin mới'`,
`chatAuthorLeft: 'Đã rời phòng'`. Reuse `t.submit` for the send button.
## Related Code Files
- Create: `web/src/lib/components/ChatPanel.svelte`
- Modify: `web/src/lib/stores/game.svelte.js` — `chat`, two arms, the `default`
arm, `kept`, `clearChat()`, `CHAT_WINDOW`
- Modify: `web/src/lib/ws/messages.js` — `sendChat(text)` builder
- Modify: `web/src/lib/components/GameBoard.svelte` — a `chat` snippet prop
- Modify: `web/src/routes/online/+page.svelte` — `say(text)`, `clearChat()` in
the mount/unmount effect, the panel in both phases
- Modify: `web/src/lib/i18n/vi.js`
## Implementation Steps
1. Add the `sendChat` builder.
2. Add `chat`, the two arms with the window trim, the `default` arm,
`clearChat()` and the `kept` entry to the store. Write the store test for
`atMs` being a number here, not in Phase 4 — `initialState()` is typed
`@returns {any}`, so `svelte-check` cannot catch a bigint or a misnamed
field, and the `deadlineUnixMs` precedent is guarded by a test for exactly
that reason.
3. Write `ChatPanel.svelte`: list, then the input with one-way mirroring and the
composition guard, then the collapsible header, then the error line.
4. Add the `chat` snippet to `GameBoard`, render the panel from the online route
in both phases, and call `clearChat()` where the route calls `reset()`.
5. Add the copy.
6. `npm run check`, then `npm test`.
## Success Criteria
- [x] A message typed in the lobby appears on both screens, in order.
- [x] Own messages are visually distinct; the other player's carry their name;
a departed author's carry the "left the room" label.
- [x] A reload shows that player's conversation, and the client list matches the
server's window exactly — no duplicate, no drift.
- [x] The list stops growing at the window; the unread badge cannot exceed it
and is reset by a history replace.
- [x] Navigating away and creating a new room shows an empty panel.
- [ ] Enter mid-composition does not send, and a Vietnamese word typed through
Telex arrives whole. *(guard implemented; harness cannot synthesise
composition events — see plan.md, Verification gaps)*
- [x] Send stays disabled for whitespace and for a pasted zero-width character.
- [x] A message containing `<b>x</b>` renders as those characters.
- [x] A `too_fast` refusal is visible in the lobby.
- [x] The panel is collapsed on the board and shows an unread count; the bot
screen has no panel at all.
- [ ] No horizontal page scroll at 360px wide; the list scrolls, the page does
not. *(bounded by CSS, checked by hand rather than asserted)*
- [x] `npm run check` reports zero errors.
## Risk Assessment
The composition guard is the one that bites: without it Vietnamese input is
unusable and the bug reads as "chat drops words". The trap is reaching for
`bind:value` to drive the disabled state — that is the write-back the comment in
`WordInput.svelte` warns about. Signal: accents mangle while typing, or the
Phase 4 diacritic case fails. Response: one-way mirroring as specified; if the
disabled state proves fiddly, enable Send unconditionally and let the server
drop the empty message rather than reintroducing a controlled input.
The unread count is client-only, so if it proves fiddly it can be dropped
without touching the server or the contract.
@@ -1,146 +0,0 @@
---
phase: 4
title: "Tests"
status: completed
priority: P1
effort: "4h"
dependencies: [2, 3]
---
# Phase 4: Tests
## Overview
One test per success criterion in the plan, at the cheapest layer that can hold
it: Go for the rules, Vitest for the store, Playwright for the two things only
a browser proves — Vietnamese input and the panel not eating the board.
## Requirements
- Functional: every criterion in `plan.md` has a test that fails without the
feature.
- Non-functional: no sleeps; wait on state the server produced, as the existing
suites do.
## Architecture
**Harness first.** `payloadCase` (`wsapi_test.go`) needs `chat_message` and
`chat_history` arms before anything else — its own comment says a missing arm
makes every `await` time out with nothing to say about why. Add a `say(text)`
helper on `testClient`. Note `silentFor` lives in `regression_test.go`, not
`wsapi_test.go` — same package, so it is usable either way.
`await` **discards** every frame before the one it wants, so it cannot assert
ordering. Any ordering claim must be checked with `recv()` in sequence — and
per Phase 2 the only true ordering claim is that the history precedes that
input's `RoomState`, which the client is explicitly not allowed to depend on.
Do not write an ordering assertion at all; assert arrival, not sequence.
Chat is broadcast to both seats, so drain both sides before asserting on a
later frame — the discipline the lobby tests already needed.
**Go — `server/internal/wsapi/`.** Reuse `pvpLobby` and `pvpRoom`.
- `TestChatReachesBothSeatsRenderedPerRecipient` — `from_me` true for the
sender, false with the author's name for the other.
- `TestChatWorksInTheLobbyAndInAGame` — one message before Start, one after.
- `TestAJoinerSeesNothingSaidBeforeTheySatDown` — owner talks alone, guest
joins, guest's history is empty. The disclosure boundary.
- `TestChatHistoryIsReplayedOnResumeInTheLobby` — talk in the lobby, drop the
socket, resume, assert the conversation comes back. The commonest refresh,
and the case a hook after `handleResume`'s `inLobby()` early return misses.
- `TestChatHistoryIsReplayedOnResumeMidGame` — the same during a game.
- `TestCreatingARoomReplaysAnEmptyHistory` — what overwrites a stale client
array.
- `TestChatHistoryIsCappedAndOrdered` — 25 messages, the window holds the last
20, oldest first.
- `TestChatHistorySurvivesAGame` — talk in the lobby, play and finish a game,
the history is still there.
- `TestChatTextIsSanitizedAndCapped` — a zero-width joiner and a bidi override
are gone; a 300-rune Vietnamese message comes back 200 runes, still valid
UTF-8; **a 199-mark stack after one base rune comes back with at most two.**
- `TestEmptyChatIsDroppedWithoutAnError` — send whitespace and a lone
zero-width character; assert silence with `silentFor`, and that a real
message still works afterwards.
- `TestChatIsRateLimitedOnItsOwnBudget` — a burst earns `too_fast`, and a
`SubmitWord` immediately after is still accepted.
- `TestSustainedChatDoesNotCloseTheOpponentsSession` — the recipient's outbox
under pressure drops chat frames rather than being closed. Drive it against a
client that stops reading; assert the session survives and the game does not
end in abandonment.
- `TestChatFromAReplacedConnectionIsRefused` — resume a seat, then send on the
retired socket: `not_your_seat`. The `occupies` guard.
- `TestChatFromAKickedConnectionIsRefused` — kick a guest whose message is
already queued, seat a replacement, assert the message is refused rather than
attributed to the newcomer.
- `TestChatNeedsASeat` — a greeted connection in no room: `not_in_a_room`.
- `TestBotRoomHasNoChat` — refused, like the lobby actions.
- `TestChatDoesNotKeepARoomAlive` — short `IdleFor`; chat throughout; the room
still closes with `room_idle_closed`.
- `TestVacatedSeatKeepsItsWordsButLosesItsAuthor` — guest talks, leaves, a third
player joins: the old message arrives with `from_me` false, an empty author,
and **no name**, checked from the remaining player's side as well as the
newcomer's.
**Vitest — `web/tests/game-store.test.js`.**
- `chatMessage` appends in arrival order with `atMs` as a number, not a bigint.
- The list stops at the window: push 25, assert 20 and that the oldest is gone.
- `chatHistory` replaces rather than merges (apply twice, no duplicates) and
resets the unread count.
- `reset()` keeps the conversation, `clearChat()` and `leave()` drop it, and
two rooms in sequence do not carry messages over.
**Playwright — `web/e2e/pvp-game.spec.js`.**
- Two players talk in the lobby, both see it; they talk again mid-game.
- A Vietnamese word typed through the composition path arrives whole. Drive it
with `insertCompositionText` via CDP or an IME-style key sequence; if the
harness cannot fake composition, assert instead that a `compositionend`-then-
Enter sequence sends exactly once and note the gap.
- A message containing markup renders as text (`getByText('<b>x</b>')`).
- After a reload the guest still sees their conversation.
- Navigating home and creating a new room shows an empty panel.
- At 360×640 the board has no horizontal scroll with the panel present.
## Related Code Files
- Modify: `server/internal/wsapi/wsapi_test.go` — `payloadCase` arms, `say`
helper, the chat tests
- Modify: `web/tests/game-store.test.js`
- Modify: `web/e2e/pvp-game.spec.js`, `web/e2e/helpers.js` (a `chat` locator
group beside `board`)
## Implementation Steps
1. Add the `payloadCase` arms and the `say` helper first — without them every
chat `await` fails as a timeout with no diagnosis.
2. Add the Go tests; run `go test ./internal/wsapi -count=2` to catch a
frame-ordering flake early, and `-race` for the goroutine-boundary cases.
3. Add the store tests; `npm test`.
4. Add the e2e cases; run with `PLAYWRIGHT_CHANNEL=chrome` when Playwright's
own Chromium is not installed.
5. Full sweep: `go test ./... -race`, `npm test`, `npm run check`,
`npx playwright test`.
## Success Criteria
- [x] Every `plan.md` criterion maps to a named test.
- [x] `go test ./... -count=2 -race` is green.
- [x] `npm test` and `npm run check` are green.
- [x] The whole Playwright suite is green, including the pre-existing cases.
- [x] No test sleeps to wait for chat, and no test asserts frame ordering.
## Risk Assessment
Faking IME composition in Playwright may not be reachable with the current
harness. Signal: the composition case cannot be written without CDP. Response:
keep the weaker `compositionend`-then-Enter assertion, state the gap in the
final report, and rely on the unit-level guard — do not delete the case and
call it covered.
`TestSustainedChatDoesNotCloseTheOpponentsSession` is the awkward one to write:
it needs a client that stops reading without closing its socket. Signal: it
cannot be made deterministic. Response: assert the narrower property — that a
chat frame to a full outbox returns false from `trySend` — as a unit test on the
session, and say so rather than dropping the criterion.
@@ -1,71 +0,0 @@
---
phase: 5
title: "Docs"
status: completed
priority: P3
effort: "30m"
dependencies: [4]
---
# Phase 5: Docs
## Overview
Chat is user-visible behaviour and a change to the wire contract, so the two
documents that describe those get a paragraph each. Nothing else moves.
## Requirements
- Functional: a reader of the README learns that rooms have chat and what its
bounds are; the deployment doc's list of fixed timings stays true — which it
does only because Phase 2 excludes chat from the idle timer. If that decision
is ever reversed, this requirement is the thing that breaks first.
- Non-functional: no new documents, no duplication of what the code already
states — link to the constants rather than restating their values twice.
## Architecture
**`README.md`, "Online play".** After the lobby paragraphs, one short
paragraph: the two players can talk, the conversation belongs to the room
rather than to a game, a player is replayed what was said while they held their
seat (so a refresh restores it and a newcomer starts at silence), and text is
sanitized by the same filter that sanitizes nicknames before anyone sees it.
Say plainly that a bot game has no chat.
**`docs/deployment.md`.** The "one timing is not configurable" paragraph is
still true — say why it survived chat: the idle window measures *no game
started*, and chatting deliberately does not reset it, so a room full of
conversation still closes on schedule. Add the two fixed chat constants
(history window and rune cap) beside it. No new section, and no env var, since
none is being added.
Check while there: does anything in either document still describe the room as
one game's container? The lobby work already fixed the obvious cases, but chat
belonging to the room is a second claim resting on the same fact.
## Related Code Files
- Modify: `README.md`
- Modify: `docs/deployment.md`
## Implementation Steps
1. Write the README paragraph in the "Online play" section.
2. Add the constants note and the idle-window clarification to
`docs/deployment.md`.
3. Re-read both sections around the edits for a stale claim about rooms.
## Success Criteria
- [x] The README says chat exists, that it is per room, that a replay is scoped
to the recipient's own occupancy, and that bot games have none.
- [x] `docs/deployment.md` states that chat does not reset the idle window,
lists the fixed chat constants, and adds no env var that does not exist.
- [x] No other document contradicts either.
## Risk Assessment
Negligible on its own, but this phase is where a reversal of Phase 2's
idle-timer decision would surface as a documentation lie rather than a bug.
Signal: `TestChatDoesNotKeepARoomAlive` removed or skipped. Response: rewrite
the deployment paragraph in the same change, never after it.
-210
View File
@@ -1,210 +0,0 @@
---
title: "Room chat"
description: "Text chat between the two players in an online room, in the lobby and during a game"
status: completed
priority: P2
effort: ""
tags: [online, wire-contract, frontend]
created: 2026-09-07
---
# Room chat
## Overview
The two people in an online room can talk to each other. Chat lives with the
room, not with a game: it works in the lobby while they agree on a game, during
the game, and after it. A player is replayed what was said while they were
sitting in their seat, so a refresh does not wipe their conversation.
The room already broadcasts per-recipient state (`RoomState`) and already
sanitizes untrusted display text (`sanitizeNickname`). Chat reuses both shapes
rather than inventing new ones.
## Decisions taken
- **Scope: the whole room.** One panel, available from the moment a player is
seated — lobby, game, and the lobby a finished game returns to.
- **History: the last 20 messages, scoped to the recipient's own occupancy.**
A seat records where the conversation stood when it was filled, and a replay
starts there. A player who reconnects gets their conversation back in full; a
player who joins a room starts at silence. The room still keeps only 20, so a
long-lived room cannot grow.
- **Abuse controls: server-side only.** A dedicated per-session token bucket, a
rune cap, and the existing sanitizer extended with a combining-mark budget.
No mute button, no filter list.
- **Chat is not room activity.** A chat message does not reset the lobby's idle
timer, so the ten-minute bound the deployment doc promises stays true and a
room cannot be held open indefinitely by typing into it.
- **A message that sanitizes to nothing is dropped without an error, and the
client makes that unreachable** by trimming and stripping zero-width
characters before it will enable Send. Every *other* refusal is answered and
shown.
- **A chat line is best-effort; a history is not.** A full outbox drops a
`ChatMessage` rather than closing the recipient's session — a lost line is
recoverable and closing a session costs the victim the game. `ChatHistory`
still goes through `send`, because it is the frame that corrects a whole
panel and there is nothing behind it.
- **A replayed history counts as what it holds, not as nothing.** A resync
keeps a folded panel's unread count honest instead of stranding it.
- **A vacated seat's messages keep their text and lose their author entirely** —
no id and no name — so neither the next occupant nor the player who stayed can
have a stranger's words attributed to them.
## Constraints
- Proto changes are additive; retired tags go to `reserved`, never reused.
- Chat text is untrusted input rendered in another player's browser. It passes
the sanitizer before it is stored or sent, and is rendered as text, never as
markup.
- Only the room goroutine touches room state; chat arrives as another input,
and every chat action authorizes with `occupies` on that goroutine — a room
code is a shared secret by design, and a connection the room has already
retired must not be able to speak as its old seat.
- Vietnamese copy stays in `web/src/lib/i18n/vi.js`; error codes stay UI keys.
- The chat input is typed with a Telex or VNI input method, so Enter must not
send mid-composition, and the field's value must never be written back into
the DOM node — see `WordInput.svelte`'s comment for why.
## Non-goals
Bot games (nobody to talk to), spectators, emotes or canned phrases, message
editing or deletion, typing indicators, read receipts, chat that outlives the
room, a mute or report control, and any profanity list.
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Two seated players can exchange text in the lobby and during a game | P1 |
| 2 | A reconnect replays what that player was present for | P1 |
| 3 | Text is capped, rate-limited and sanitized before anyone sees it | P1 |
| 4 | Chat cannot cost a player their session, their room, or their game | P1 |
| 5 | The panel does not crowd the board on a phone | P2 |
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Phase 1: Wire contract](./phase-01-start.md) | Done |
| 2 | [Phase 2: Room chat](./phase-02-room-chat-server.md) | Done |
| 3 | [Phase 3: Client chat panel](./phase-03-client-chat-panel.md) | Done |
| 4 | [Phase 4: Tests](./phase-04-tests.md) | Done |
| 5 | [Phase 5: Docs](./phase-05-docs.md) | Done |
Phases run in order. 2 depends on 1; 3 depends on 1 and 2; 4 depends on 2 and 3.
## Success Criteria
- [x] A seated player can send a message and the other player sees it, in the
lobby and during a game.
- [x] Each side sees its own messages marked as its own, and the author's
server-sanitized name on the other's.
- [x] A player who reloads is shown their conversation again; a player who
joins a room is shown nothing said before they sat down.
- [x] History survives a game start and a game end; it dies with the room.
- [x] The client's list and the server's history hold the same 20 messages
after a replay, and the unread badge cannot outlive them.
- [x] A message longer than the cap is truncated at a rune boundary, and a
stack of combining marks is truncated with it.
- [x] Control and format characters cannot reach another player's screen, and
no message is rendered as markup.
- [ ] Every refusal is answered and visible: `too_fast` on a burst, `busy` on a
dropped input, `not_your_seat` from a retired connection — including in
the lobby, which today renders no error at all.
- [x] A burst does not consume the sender's move budget, and sustained chat
does not close the recipient's session.
- [x] Chat from a connection with no seat, from a connection the room has
replaced, and from a bot room are all refused.
- [x] A room with chat traffic and no game still closes on the idle timer.
- [x] A seat that is vacated leaves its messages with no author at all, so
neither player can be shown a stranger's words as somebody's.
- [ ] Enter mid-composition does not send a half-typed Vietnamese word.
## Verification gaps
Everything above is implemented. These paths are implemented but not proven by
a test, and are listed here rather than ticked:
- **Enter mid-composition** (criterion 13). The guard is `oncompositionstart` /
`oncompositionend` around a submit that reads the element, identical in shape
to `WordInput.svelte`'s. Playwright in this harness cannot synthesise
composition events, which the plan anticipated; covered by review only.
- **`busy` on a dropped input** (part of criterion 8). Filling a 32-slot room
inbox deterministically is not reachable from a test client. `too_fast` is
covered on both sides — refused on the server, visible in the lobby panel —
and `not_your_seat` is covered on the server.
- **`vacate`'s kick and grace-expiry paths.** The re-sync is asserted for a
voluntary leave only; the other two callers were traced by reading.
- **The departed-author label.** The server clears attribution and two tests
assert it; nothing asserts the client renders "Đã rời phòng" for it.
- **`ChatPanel.svelte` has no component test**, because the repo has no Svelte
component suite. Everything in that file is covered end-to-end or not at all.
- **The 360px board layout** (goal 5). Bounded by `max-height` and
`overflow-wrap: anywhere`, checked by hand rather than asserted.
## Risks
- **The panel eats the board on a phone.** Mitigation: collapsed by default
during a game with an unread count; the lobby shows it expanded. Signal: the
e2e board assertions start needing scrolling. Response: make it a sheet.
- **The unread badge is client-only state derived from a server counter.** Two
reviewers found the same defect in it by reading, twice: first counting a
capped list's length, then going quiet after a resync reset the counter under
it. It is now `chatCount` set to the replayed length and a `seenAt` that
follows a decrease. Signal: a badge that stops counting, or one that counts
what was already read. Response: the arithmetic is four lines in one
component — but add the store test before the fix, not after.
- **A client applies `ChatHistory` before it has a room.** The history is sent
from the handler and therefore *precedes* that input's `RoomState`, which is
broadcast from the run-loop tail. The client must not depend on the order —
`chatHistory` replaces wholesale and reads no `RoomState`-owned field.
Signal: a panel rendering with an empty room code. Response: render the panel
from `phase`, never from the presence of messages.
- **The combining-mark budget is a judgement call, not a standard.** Signal: a
legitimate Vietnamese message loses a mark it needed. Response: the budget is
one constant in one function — raise it; do not remove the cap.
## Red Team Review
### Session — 2026-09-07
**Findings:** 15 distinct, deduplicated from 28 raw across 3 reviewers
(15 accepted, 0 rejected)
**Severity breakdown:** 3 Critical, 7 High, 5 Medium
**Reviewers:** Security Adversary (Fact Checker), Failure Mode Analyst (Flow
Tracer), Assumption Destroyer (Scope Auditor). Verification tier: Full.
**Evidence filter:** every finding carried `file:line` citations; none rejected
for want of evidence.
| # | Finding | Severity | Disposition | Applied To |
|---|---------|----------|-------------|------------|
| 1 | `chat` in the store's `kept` set leaks one room's conversation into the next; `reset()` also runs on route mount/unmount, and `handleCreate` sends no history to heal it | Critical | Accept | Phase 2, 3, 4 |
| 2 | Replay ordering inverted — `broadcastRoomState` runs in the run-loop tail — and `handleResume` early-returns for a lobby resume, so the commonest refresh never replays | Critical | Accept | Phase 2, 4, plan.md |
| 3 | `handleChat` specified without `occupies`; the claim that `toRoom` does a seat check is false. Bot-room check cannot live in `dispatch` — `r.strategy` is room-goroutine-only | Critical | Accept | Phase 2, 4 |
| 4 | Chat resets the lobby idle timer, so a room can be held open forever and Phase 5's "timings stay true" is unsatisfiable | High | Accept | Phase 2, 4, 5 |
| 5 | A separate chat budget is additive to the existing ones, and a full outbox closes the recipient's session — sustained chat can disconnect an opponent into an abandonment loss | High | Accept | Phase 2, 4 |
| 6 | "Empty message is unreachable" is false — spaces or a pasted zero-width character pass the blank check | High | Accept (user decision: fix the client's blank check, keep the silent drop) | Phase 3, 4, plan.md |
| 7 | The 20-message replay discloses a private conversation to any stranger who redeems the code, and lets an owner pre-load a payload for joiners | High | Accept (user decision: replay scoped to the recipient's own occupancy) | Phase 2, 4, plan.md |
| 8 | The sanitizer drops control and format characters but not combining marks; at 200 runes that is a render bomb that persists in history | High | Accept | Phase 2, 4 |
| 9 | A dropped `chatInput` answers nothing, unlike every other input; and the lobby renders no error surface at all | High | Accept | Phase 2, 3, 4 |
| 10 | A controlled chat input cannot copy `WordInput`'s deliberately-uncontrolled composition guard verbatim; `maxlength` counts UTF-16 units, not runes | High | Accept | Phase 3 |
| 11 | The client array is unbounded (the cap is server history only) and the unread badge diverges from the list on a replace | Medium | Accept | Phase 3, 4 |
| 12 | Keeping a departed player's `name` lets a newcomer take that nickname — `distinguish` only checks the seated opponent — and contradicts Phase 1's own proto comment | Medium | Accept | Phase 1, 2, 4 |
| 13 | The `ProtocolVersion` justification is false: chat is pushed to old clients, which drop it silently for want of a `default:` arm | Medium | Accept | Phase 1, 3 |
| 14 | Phase 3's success criteria mostly require Phase 2's server, but its dependencies said `[1]` | Medium | Accept | Phase 3, plan.md |
| 15 | Four false citations: `ChainHistory` scrolls to top unconditionally; tag maxima are 12/12; `silentFor` lives in `regression_test.go`; `payloadCase` needs new arms — plus "JSDoc keeps the panel honest" when the store's state is typed `any` | Medium | Accept | Phase 1, 3, 4 |
### Whole-Plan Consistency Sweep
- Files reread: plan.md, phase-01-start.md, phase-02-room-chat-server.md,
phase-03-client-chat-panel.md, phase-04-tests.md, phase-05-docs.md
- Decision deltas checked: 7 — occupancy-scoped replay; chat excluded from idle
activity; best-effort chat delivery; `occupies` on every chat input; author
cleared entirely on vacate; client blank check strict; client list capped to
the server's window
- Reconciled stale references: 9 — the replay-ordering claim in plan.md Risks
and Phase 2; "last 20 on join" in plan.md Decisions, Goals and Phase 4; the
`toRoom` seat-check claim in Phase 2; the `ProtocolVersion` justification and
tag maxima in Phase 1; the `name`-retention rule in Phase 2 against Phase 1's
proto comment; the `ChainHistory` scroll precedent and the JSDoc claim in
Phase 3; Phase 3's dependency list; Phase 5's "timings stay true"
- Unresolved contradictions: 0
@@ -1,48 +0,0 @@
# Phase 1 — Engine: elimination and standings
`server/internal/game/engine.go`, `state.go`.
The engine already carries `players []PlayerID` and advances `turnIndex` modulo
its length, so seating more than two costs nothing. What is two-player is the
*ending*: `opponentOf` picks the winner, and any single failure finishes the
game.
## What changes
- `alive []bool` parallel to `players`, plus `aliveCount`.
- `outOrder []PlayerID` — elimination order, first out first. Rank is derived
from it, so nothing has to be recomputed on read.
- `outReason map[PlayerID]EndReason` — why each player went out.
- `Turn()` returns the next **living** player; `advance()` skips the dead.
- `eliminate(p, reason)` marks a player out, appends to `outOrder`, and finishes
the game when one player is left.
- `expire(now)` eliminates the player to act, then keeps eliminating the next to
act while the position has no legal move — which is what stops a dead end from
costing every remaining player a full turn clock each.
- `NoMove()` and `Resign(p)` route through `eliminate` too. `Resign` works out
of turn: a player may give up while somebody else is thinking.
- `Alive(p) bool` and `Standings() []Standing` for the transport layer.
- Delete `opponentOf`.
```go
// Standing is one player's final placing. Rank 1 is the winner; a player
// eliminated later ranks above one eliminated earlier.
type Standing struct {
Player PlayerID
Score int
Rank int
Reason EndReason // EndNone for the winner
}
```
`State` gains `Standings []Standing` and `Alive map[PlayerID]bool`.
## Invariants worth a test
- Two players: every existing outcome is identical to today's.
- Four players: A times out → A out, turn passes to B on the **same syllable**,
scores and used set survive.
- A dead end closed by A eliminates B, C and D in one call and leaves A standing.
- `Resign` out of turn eliminates the resigner and does not disturb whose turn
it is, unless the resigner *was* the player to act.
- Rank ordering: survivor 1, then reverse `outOrder`.
@@ -1,61 +0,0 @@
# Phase 2 — Wire contract and codegen
`proto/noitu/v1/game.proto`, regenerated into `server/gen/` and
`web/src/lib/proto/`. `ProtocolVersion` → **2**.
## New messages
```proto
// PlayerSlot is one seat in the room, rendered for one recipient.
message PlayerSlot {
string player_id = 1; // "p1".."p4" — stable within a room
string name = 2;
bool is_me = 3;
bool is_owner = 4;
bool ready = 5;
bool connected = 6;
}
// PlayerScore is one player in a running or finished game.
message PlayerScore {
string player_id = 1;
string name = 2;
bool is_me = 3;
uint32 score = 4;
bool eliminated = 5;
bool connected = 6;
uint32 rank = 7; // final placing, 1 = winner; 0 while in play
}
// PlayerEliminated is one player leaving a game that is still running.
message PlayerEliminated {
string player_id = 1;
string name = 2;
bool is_me = 3;
GameEndReason reason = 4;
// What the position still had, sent only to the player who went out.
repeated string suggestions = 5;
}
```
## Changed messages
| Message | Change |
|---|---|
| `RoomState` | `reserved 2, 4, 5, 6, 7, 8` (`i_am_owner`, `i_am_ready`, `opponent_*`). Adds `repeated PlayerSlot players = 9`, `uint32 max_players = 10`, `uint32 min_players = 11`, `uint32 grace_ms = 12`. `room_code` and `can_start` stay. |
| `GameStarted` | Adds `repeated PlayerScore players = 7`, `string turn_player_id = 8`. |
| `TurnUpdate` | `reserved 6, 7` (`my_score`, `opponent_score`). Adds `repeated PlayerScore players = 9`, `string turn_player_id = 10`. `played` may now be absent — an elimination advances the turn without a word. |
| `GameOver` | `reserved 3` (`my_score`). Adds `repeated PlayerScore standings = 6`, ordered by rank. |
| `PlayedWord` | Adds `string player_id = 6`, so a four-way chain says who played what without matching names. |
| `KickPlayer` | Adds `string player_id = 1` — the owner now names a seat. |
| `OpponentLeft` | Retired. `ServerMessage` reserves tag 8; presence travels on `RoomState.players[].connected` with `RoomState.grace_ms`, which is broadcast mid-game already. |
| `ServerMessage` | `reserved 2, 3, 8, 11`; adds `PlayerEliminated player_eliminated = 15`. |
`i_am_owner` / `i_am_ready` go because the recipient's own row already carries
them: two encodings of one fact are two ways for a client to disagree with the
server.
## Regeneration
`buf` is not on this machine — `go install github.com/bufbuild/buf/cmd/buf@latest`
first, then `make proto`. Both generated trees are committed.
@@ -1,60 +0,0 @@
# Phase 3 — Room: N seats, N grace windows
`server/internal/wsapi/room.go`, `codec.go`, `hub.go`, `session.go`,
`nickname.go`.
## Seats
- `maxPlayers = 4`, `minPlayers = 2`, `seatIDs = [maxPlayers]game.PlayerID{"p1".."p4"}`.
- `seats [maxPlayers]*seat`. `freeSeat`, `occupied`, `seatOf` already loop.
- `distinguish(name, taken)` becomes `distinguish(name, taken []string)` and
counts up (" 2", " 3", …) until the name is unused.
- Drop `otherNickname` / `opponentSeat`; add `seatsInOrder()` yielding the
occupied seats in seat order, which is also engine turn order.
## Lobby
- `canStart()` — no bot strategy, in lobby, at least `minPlayers` seated, owner
connected, every non-owner seat connected and ready.
- `lobbyStart` refuses with `need_more_players` / `player_offline` /
`not_everyone_ready`.
- `lobbyKick` reads `KickPlayer.player_id`: refuse `no_one_to_kick` when the
seat is empty, `cannot_kick_self` when it is the owner's own, and
`player_is_ready` when that player is ready — the existing rule, per target.
- `handleJoin` refuses a full room exactly as now.
## Grace windows
One timer is no longer enough: any number of seats can be inside their window at
once. Each `seat` gains `graceUntil time.Time`; `resetGraceTimer()` arms a single
timer for the earliest of them, and on fire every seat past its deadline is
expired together. That keeps one timer and one wakeup regardless of how many
players dropped.
A dropped seat mid-game is **not** skipped: the engine's turn clock runs for it,
so a player who drops on their own turn is eliminated by timeout before their
window is up. The window only decides whether they are still in the game after it.
## Eliminations
`applyEliminations(before int)` — compare the engine's `outOrder` length against
what it was, send each newly eliminated player's `PlayerEliminated` to every
seat (with `suggestions` only in the copy that goes to the player who went out),
then:
- game over → `broadcastGameOver` with standings;
- otherwise → `r.turnSeq++` and `broadcastTurn(nil)`, so every client learns the
new deadline and whose turn it is without a word having been played.
The room keeps `outWire map[game.PlayerID]noituv1.GameEndReason`, because
`OPPONENT_LEFT` is a transport fact the engine never learns: a grace expiry
eliminates with `EndResigned` and is reported as `OPPONENT_LEFT`.
`GameOver.reason` stays the game-level reason — the reason the **last**
elimination happened — which keeps 1v1 wording identical.
## Broadcasts
- `broadcastRoomState` builds `PlayerSlot` rows in seat order.
- `broadcastTurn(move *game.Move)` and `sendGameStarted` build `PlayerScore` rows.
- `gameOverFor` builds standings once and sets `is_me` per recipient.
@@ -1,37 +0,0 @@
# Phase 4 — Client: the room as a list of players
## Store — `web/src/lib/stores/game.svelte.js`
`opponentName` / `opponentScore` / `opponentPresent` / `opponentReady` /
`opponentConnected` / `isOwner` / `isReady` / `opponentLeft` all collapse into:
```js
/** @type {PlayerSlot[]} */ roomPlayers: [], // from RoomState
/** @type {PlayerScore[]} */ gamePlayers: [], // from GameStarted / TurnUpdate
/** @type {PlayerScore[]} */ standings: [], // from GameOver
maxPlayers: 4,
minPlayers: 2,
graceMs: 0,
turnPlayerId: '',
/** @type {{ playerId, name, isMe, reason, suggestions } | null} */
lastElimination: null,
```
with derived getters `me`, `isOwner`, `isReady`, `myScore`, `iAmEliminated`.
A `turnUpdate` with no `played` is an elimination advancing the turn — push
nothing to the chain, apply the rest.
## Components
| File | Change |
|---|---|
| `Lobby.svelte` | Renders `roomPlayers` plus empty slots up to `maxPlayers`. Kick button per row, on any guest who is not ready. Hints keyed off how many are seated and how many are ready. |
| `ScoreBoard.svelte` | A row per player, active row = `turnPlayerId`, eliminated rows struck through. Replaces the two-sided layout. |
| `PlayerStatus.svelte` | Replaces `OpponentStatus.svelte`: one line per disconnected player counting down `graceMs`, plus an "eliminated" banner for this player. |
| `GameOverPanel.svelte` | Standings table from `standings`; `myScore` read from the row where `isMe`. Suggestions still only for a player who did not win. |
| `ChainHistory.svelte` | Labels each entry with its player's name via `playerId`. |
| `GameBoard.svelte` | Drops `opponentLabel`; word input hidden while `iAmEliminated`. |
| `online/+page.svelte` | `kick(playerId)`. |
| `messages.js` | `kickPlayer(playerId)`, `PROTOCOL_VERSION = 2`. |
| `history-export.js` | Names each mover from the player list rather than "you / opponent". |
| `i18n/vi.js` | New keys: standings, eliminated, spectating, per-player disconnect, `need_more_players`, `cannot_kick_self`, `player_offline`. |
@@ -1,30 +0,0 @@
# Phase 5 — Tests and docs
## Go
- `game/engine_test.go` — the phase 1 invariants: four-player elimination order,
dead-end cascade, out-of-turn resign, standings ranks, and every existing
two-player case unchanged.
- `wsapi/wsapi_test.go` — a four-player room: join to full, fifth refused; start
refused until all ready; kick by id; a full game with two eliminations; an
eliminated player still receiving turn updates and chat.
- `wsapi/regression_test.go` — reconnect inside and outside the window with more
than two players seated.
- `wsapi/wire_test.go` — fixtures for the new and changed messages;
`go test ./internal/wsapi -update` rewrites `proto/testdata/`.
- `wsapi/convert_test.go` — unchanged mappings still exhaustive.
## JavaScript
- `tests/game-store.test.js` — the new projection, including a `turnUpdate` with
no `played`.
- `tests/game-wire.test.js` — decodes the regenerated fixtures.
- `tests/error-codes.test.js` — every server code has Vietnamese copy.
- `e2e/pvp-game.spec.js` — a three-player game through the browser.
- `e2e/reconnect.spec.js` — unchanged 1v1 path still passes.
## Docs
- `README.md`: "online 1v1 by room code" becomes rooms of two to four, and the
elimination rule joins the opening description.
- `docs/deployment.md` only if a knob changed — it did not.
@@ -1,55 +0,0 @@
# Rooms of two to four players
Status: **done** — 2026-09-08. Phases 1–5 delivered and verified, except the
Playwright suite, which could not run on this machine: the browser download
times out here. The specs are written and parse; they have not been executed.
A room seats up to `maxPlayers` (4, one constant) instead of exactly two. The
owner starts once every guest is ready; two seated players is enough. A player
who fails their turn is eliminated and the rest play on, last standing wins.
## The rules this settles
| Question | Answer |
|---|---|
| Room size | 2–4 seats. `maxPlayers` is one server constant, sent to the client in `RoomState` so the lobby renders whatever the server allows. |
| Starting | Owner only, and only when every other seated player is connected and ready. Two players is the minimum. |
| Kicking | Owner only, names a seat, refused when that player is ready — the existing rule, now per-target. |
| Failing a turn | The player who runs out of clock is **eliminated**; the syllable and the used set survive, the turn passes to the next living player. Last standing wins. |
| A dead-end position | The player to act still burns their clock and goes out, as today. Everyone after them is then eliminated at once rather than each waiting out a turn they cannot answer — which leaves the player who closed the position standing, exactly the 1v1 outcome. |
| Disconnecting mid-game | The seat's clock keeps running, so a player who drops on their own turn can time out and go out normally. Otherwise their reconnect window decides it: come back and play on, or be eliminated when it expires. |
| Being eliminated | They keep their seat and watch: live chain, live turns, chat still works, no word input. Everyone lands back in the same lobby when the game ends. |
| Game over | Full standings — every player, their score, ranked by finishing order (last standing first, then reverse elimination order). |
## Wire contract
This breaks the two-player shape of `RoomState`, `TurnUpdate`, `GameOver` and
`OpponentLeft`, so `ProtocolVersion` goes to **2** and the retired field tags go
to `reserved`. That is what the version number is for: an old client is refused
with a readable error rather than decoding a frame that means something else now.
## Phases
| # | Phase | Depends on |
|---|---|---|
| 1 | [Engine: elimination and standings](./phase-01-engine-elimination.md) | — |
| 2 | [Wire contract and codegen](./phase-02-wire-contract.md) | — |
| 3 | [Room: N seats, N grace windows](./phase-03-room-seats.md) | 1, 2 |
| 4 | [Client: the room as a list of players](./phase-04-client.md) | 2 |
| 5 | [Tests and docs](./phase-05-tests-docs.md) | 1–4 |
All five are complete. `go vet`, `go test ./... -race` and `npm test` pass;
`npm run check` reports no problems. `npx playwright test` is blocked on the
browser download, not on the code.
## Acceptance criteria
- A room seats 2–4; a fifth join is refused with `room_full`.
- Owner's start button is refused until every other seated player is ready.
- The owner can kick any named guest who is not ready.
- A 4-player game eliminates on timeout and continues; the last player standing
wins and everyone sees the same standings.
- An eliminated player still sees the board and can chat, and returns to the
lobby with the others.
- 1v1 behaviour is unchanged end to end, including reconnect and vs-bot.
- `go vet`, `go test -race`, `npm test` and the Playwright suite all pass.
@@ -1,184 +0,0 @@
# Audit: proper-noun drops and the wiktionary-only corpus
Date: 2026-09-08. Phase 3 of `plan.md`. Every number here is reproducible from the
commands listed; the shipped `data/noitu.db` was copied aside before any build and is the
"old" side of every comparison.
## Commands
```sh
# from server/, after `make fetch-dict`
go run ./cmd/build-dictionary --merged ../data/undertheseanlp-words.jsonl \
--out /tmp/wik.db --report-drops /tmp/drops-wik.txt
go run ./cmd/build-dictionary --merged ../data/undertheseanlp-words.jsonl \
--sources hongocduc,wiktionary --out /tmp/hw.db --report-drops /tmp/drops-hw.txt
# sample: python, random.seed(1), random.sample(sorted(drops-wik), 100)
# casualties: (drops-wik − drops-hw) ∩ words(hw.db)
go test ./internal/bot/ -run RealCorpus -v # once per database at data/noitu.db
```
## Build result
```
rejected 97: contains punctuation
rejected 5074: fewer than 2 syllables
rejected 37: no Vietnamese letters
rejected 4747: only ever capitalized
accepted 22419 distinct words (sources: wiktionary)
```
22,419 vs the 22,310 the research report measured. The delta is the rule: the report
dropped any form containing an uppercase letter, the builder drops forms that *begin* with
one, so `tia X`, `bộ bài tây`, `chữ Hán`, `châu Á` survive. All are ordinary words.
## 1. Drop audit — 100 sampled, seed 1
Verdicts: **P** proper noun (correct drop) · **X** not a Vietnamese word (would be rejected
by the filter anyway) · **U** unclear · **C** common word (false positive).
| | | | | |
|---|---|---|---|---|
| a yun P | an hoà tây P | an minh bắc P | an thuận P | ba vinh P |
| ban cơ U | brao X | bàn đạt P | bành trạch P | bàu năng P |
| bá xuyên P | bình hiệp P | bình tấn P | bằng la P | chiềng sơ P |
| châu hội P | chù lá phù lá P | chư krêy P | cur X | côn đảo P |
| cơ kiều P | cẩm trung P | hùng vương P | hồng bàng P | irc X |
| khánh gia P | lão quân P | lương đài P | mặc dương P | nhơn hoà lập P |
| ninh lai P | ninh thạnh P | noong bua P | nội thôn U | phiếu hữu mai U |
| phù lảng P | phương cao kén ngựa U | phần lão U | quán hành P | quắc hương P |
| rã bản P | sam mứn P | sơn hạ P | sơn vy P | tam ngọc P |
| thanh liên P | thanh văn P | thiệu đô P | thái an P | thượng lâm P |
| thượng tiến P | thạch xuân P | thới quản P | tiên cát P | tiên thọ P |
| tri lễ P | triệu giang P | triệu việt vương P | trung tú P | trà khê P |
| trà kót P | trà linh P | trường khánh P | trường long P | trần đình thâm P |
| trọng do P | tuân lộ P | tân hưng P | tân mỹ P | tân nhựt P |
| tân trì P | tây hiếu P | tăng sâm P | tĩnh gia P | tịnh an P |
| tốt động P | vinh an P | việt–mường X | vân canh P | võ trường toản P |
| võ văn tồn P | văn môn P | văn quân P | văn đình dận P | vĩnh hoà hưng bắc P |
| vĩnh lương P | vĩnh lộc b P | vĩnh trinh P | vũ thư P | vạn thuỷ P |
| xuân mai P | xuân quan P | xá bung P | xá cẩu P | xá khắc P |
| xốp cộp P | yên cát P | ô qua U | đồng nai P | đổ rượu ra sông thết quân lính C |
**P 89 · X 4 · U 6 · C 1.** Gate is ≤2 common words in 100: **passes.** The bulk is
commune names, ethnonyms and historical persons — the content the rule exists to remove.
## 2. Case casualties — 215, listed in full
Words the wiktionary rows hold only capitalized, which another branch has lowercase. They
are dropped under the plan's wiktionary-only evidence rule. Full list:
`case-casualties.txt` in the session scratchpad; reproduced here because it is the
decision input.
an bình · an dân · an hảo · an khang · an lạc · ba tiêu · biển hồ · bu lu · **bàn là** ·
**báo đáp** · bát tiên · bình chuẩn · bình chân · bình khang · bình nghị · bình sa · bình
thanh · bình thuỷ · bình trị · bình tâm · bình văn · bình điền · bích đào · bóng chim tăm
cá · bông trang · bạch cung · bản nguyên · **bảo toàn** · bắc phong · bẻ quế · bố chính ·
**bồ đề** · bồng sơn · cao biền dậy non · cao kỳ · cao nhân · cao sơn · cao tổ · cao xanh ·
cao đường · chà và · chày sương · chánh hội · **chân mây** · châu lệ · châu thành · chí
thiện · chí thành · chính tâm · **chúa nhật** · chúa trời · chị hằng · con tạo · cát lũy ·
cát tân · cát đằng · **công bình** · công dã tràng · **công giáo** · **công nguyên** ·
**cơ đốc giáo** · cường thịnh · cầm đuốc chơi đêm · cầu lam · cầu lộc · cầu ô · cẩm châu ·
cẩm đường · cửu kinh · **cựu ước** · diêm vương · diêm vương tinh · dã hạc · dương quan ·
dương đài · **dường như** · **giao tử** · giấc mai · giọt châu · giọt tương · hoa cái ·
**hoa kiều** · **hoà đồng** · hoá công · hoả tinh · hán học · hán tộc · hán tự · hán văn ·
hạ thần · hải phòng · hải triều · hải vương tinh · hằng nga · **hết sảy** · **hệ mặt
trời** · hội gió mây · hội long vân · **hồi giáo** · **khổng giáo** · kim tinh · **kinh
thánh** · la ve · lâm viên · **lưỡi hái** · lạc hầu · mạnh thường quân · **mặt trời** · nam
lâu · **nguyên đán** · **nho học** · nhân kiệt · năm cha ba mẹ · nước dương · nếm mật nằm
gai · nợ như chúa chổm · phong thu · **phù phiếm** · **phật giáo** · phật học · phật pháp ·
phật tiền · phật tổ · phật tự · **phật đản** · quang phục · quyết tiến · quân thiều · **quả
đất** · **sao hôm** · sao hỏa · sao kim · **sao mai** · sao mộc · song mai · sơn cương ·
sơn lâm · sơn mai · sơn nguyên · sơn trung · sư tử hà đông · **sừng trâu** · tam dân · tam
phủ · tam sơn · thanh nghị · thanh phong · thanh quang · thanh vận · thiên chúa · **thiên
chúa giáo** · thiên vương tinh · thuỷ liễu · thân giáp · thạch bàn · thạch thán · **thần
chết** · **tin lành** · tiên sư · **trái đất** · trướng huỳnh · trường giang · trường xuân ·
tu lý · tuần duyên · tân kỳ · **tân ước** · tây dương · tây thiên · tô hạp · tùng lâm · tế
tân · **tết nguyên đán** · **tổ quốc** · tử phòng · u minh · vinh thăng · **việt ngữ** ·
vách quế · vân hà · vân hán · vân trình · võ miếu · **văn giáo** · **văn miếu** · **văn
nhân** · văn quan · **văn võ** · văn đức · **vũ công** · **vũ hội** · vũng tàu · vương bá ·
vạn an · vạn kiếp · vạn phúc · vầng ô · **vớ bở** · **xe tơ** · xuân hoá · xuân phong ·
xuân tình · xích thố · xương thịnh · yên bình · yên chi · yên hoa · yên hà · đinh điền ·
**đường luật** · **đường thi** · đại danh · đỉnh giáp non thần · **địa cầu** · đỗ vũ
Bold: everyday or dictionary-headword vocabulary by this auditor's reading, ~55 of 215.
The rest are Sino-Vietnamese literary terms, religious/astronomical names that Wiktionary
capitalizes by convention, and genuine place names (`hải phòng`, `vũng tàu`, `bồng sơn`).
**Narrowing does not fix this.** The plan's pre-decided narrowing — drop only when every
syllable is capitalized — was measured: drops fall 4,747 → 4,337, casualties fall only
215 → 147 (`mặt trời`, `trái đất`, `tổ quốc`-style two-cap forms stay dropped), and 410
proper nouns come back in, including junk like `bản mẫu:-vie-n-` and `con kde`. Rejected.
## 3. Corpus diff — old shipped vs new
| | |
|---|---|
| overlap | 21,320 |
| gained | 1,099 (`gân bò`, `rèn đúc`, `ích kỷ`, `đáng lý`, `thở hồng hộc`) |
| lost | **26,896** |
| — dropped as proper noun | 4,245 |
| — absent from the wiktionary branch | 22,651 |
| — other | 0 |
## 4. Playability
| metric | old | new |
|---|---|---|
| words | 48,216 | 22,419 |
| syllables | 6,676 | 5,485 |
| syllables that open a word | 5,049 | 4,052 |
| …with ≥2 continuations | 3,682 | 2,691 |
| dead-end syllables | 1,627 | 1,433 |
Bot-versus-bot, `internal/bot` real-corpus tests, 60 games each:
| | old | new |
|---|---|---|
| hard-vs-easy win rate (moves) | 98% (3.3) | 98% (3.5) |
| medium-vs-easy | 87% | 88% |
| hard-vs-medium (moves) | 72% (3.5) | 55% (2.9) |
| easy-vs-easy game length | 17.5 moves | **11.3 moves** |
| hard decision p95 | 5.2 ms | 1.1 ms |
Both tests pass on both databases. Easy-vs-easy games are a third shorter: the thinner
graph runs out of continuations sooner, which is the depth loss the plan predicted.
Hard-vs-medium falling to 55% means the graph gives the stronger bot fewer ways to trap.
## 5. Decision
Drop rule on the sample: **pass** (1 common word in 100).
Case casualties: **open — needs the owner's call**, because the options trade the
"wiktionary data only" decision against ~55 everyday words:
- **Accept** the 215 losses as measured.
- **Exception list**: a checked-in list of words to keep despite capitalization, curated
from the 215 above. Cause-aligned, small, but a hand-maintained artifact.
- **Read hongocduc for case evidence only**: recovers all 215 mechanically, but uses GPL
data as an input even though none of its words ship. Must be stated in `ATTRIBUTION.md`.
**Recorded decision (owner, 2026-09-08): abandon the capitalization rule.** Every
wiktionary-tagged word is kept regardless of case; proper nouns stay in the corpus as they
are in the shipped database today. The rule, its reject reason and `--report-drops` were
removed from the builder.
## 6. Final corpus, without the rule
```
accepted 26845 distinct words (sources: wiktionary)
```
| metric | old | new |
|---|---|---|
| words | 48,216 | 26,845 |
| syllables | 6,676 | 5,709 |
| syllables that open a word | 5,049 | 4,158 |
| …with ≥2 continuations | 3,682 | 2,787 |
| dead-end syllables | 1,627 | 1,551 |
| overlap / gained / lost | | 25,565 / 1,280 / 22,651 |
Every lost word is absent from the 2018 wiktionary branch; none is lost to a rule.
Bot-versus-bot, 60 games each: hard-vs-easy 95% (2.7 moves), medium-vs-easy 87%,
hard-vs-medium 63% (3.2 moves), easy-vs-easy **12.9 moves** (old: 17.5). Both real-corpus
tests pass.
@@ -1,118 +0,0 @@
---
phase: 1
title: "Phase 1: Read the merged list"
status: done
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Read the merged list
## Overview
Teach `build-dictionary` a third input: undertheseanlp's merged JSONL. It selects words by
source membership and hands them to the normalization and filtering path that already
exists.
## Requirements
- Functional: read `{"text": "...", "source": ["hongocduc", ...]}` lines; keep a word when
its sources intersect the allowed set — **`wiktionary` only** by default.
- Functional: `hongocduc` and `tudientv` rows are skipped before any decision is made about
a word. They inform nothing.
- Functional: capitalization is not a filter. `Hà Nội` and `hà nội` are the same word and
land as the lowercase form, exactly as every other input mode already behaves.
- Functional: the `--min-words` default moves from 40,000 to 20,000. The new corpus is
~26,800 words; the old floor would reject it.
- Non-functional: streaming line-by-line. The file is 4.8 MB; nothing here needs to be
clever.
- Non-functional: everything downstream — `accept()`, syllable indexing, alias generation,
`verify()`, `meta` — is reused unchanged.
## Architecture
```
--merged <file> --sources wiktionary
│
▼ one row at a time
sources ∩ allowed ≠ ∅ ? ── no ──▶ skipped, never counted
│ yes
▼
accept() (NFC, lowercase, ≥2 syllables, alphabet, phonotactics)
│
▼
finish() (floor, aliases, atomic write, verify) ← shared with --words and --in
```
The `--sources` flag is kept general — a comma list validated against the three known
names — even though only `wiktionary` is passed, so a comparison build against another
branch is a flag away and never a code patch. Unknown names are an error, not a no-op.
`finish()` is new: the three input modes used to each carry their own copy of the floor
check, alias generation, write and verify. One copy is what keeps a fixture from drifting
into a different shape from the database production loads.
## Related Code Files
- Created: `server/cmd/build-dictionary/merged_list.go`
- Created: `server/cmd/build-dictionary/merged_list_test.go`
- Modified: `server/cmd/build-dictionary/main.go` — `--merged` and `--sources` flags,
mutually-exclusive input check, `finish()`, `sourceSpec.url` / `sourceSpec.extra`, meta
rows, package doc comment, `--min-words` default
- Unchanged: `server/cmd/build-dictionary/filter.go`
## Implementation Steps
1. Write `merged_list.go`: decode with `encoding/json` per line (`bufio.Scanner` with a
raised buffer — a scanner that silently truncates is a bad way to lose words), skip rows
with no allowed source, feed the rest to `accept()`.
2. Add `--merged <file>` and `--sources wiktionary` (the default) to `main.go`. Change the
`--min-words` default to 20,000 in the same edit; the fixture path passes its own floor
explicitly and is unaffected.
3. Dispatch in `run()`: exactly one of `--merged`, `--words`, `--in` may be given. Refuse
two rather than picking one.
4. Write `source_url`, `source_commit`, `sources_kept` and `sources_excluded` into `meta` —
provenance in the artifact, not only in a markdown file.
5. Tests in `merged_list_test.go`, table-driven, over hand-written JSONL fixtures:
- `Hà Nội` tagged wiktionary → kept as `hà nội`
- a `hongocduc`-only word and a `tudientv`-only word → absent from the output
- `học sinh` in all three, plus `Học sinh` → kept once
- a malformed line → a clear error naming the line number, not a skipped word
- `--sources hongocduc,wiktionary` → strictly more words; `tudientv` still absent
- meta rows as above
6. Run against the real file and record the counts.
## Result
```
rejected 2: contains a digit
rejected 147: contains punctuation
rejected 5335: fewer than 2 syllables
rejected 87: no Vietnamese letters
accepted 26845 distinct words (sources: wiktionary)
generated 1507 spelling aliases
```
The first cut of this phase also implemented a capitalization-based proper-noun drop
(`only ever capitalized` → rejected, `--report-drops` for audit). It reached the Phase 3
gate, was audited, and was removed by the owner's decision — see `plan.md` and
`audit-proper-noun-drops.md`. Nothing of it remains in the code.
## Success Criteria
- [x] `go test ./cmd/build-dictionary/` passes, new tests included.
- [x] Building from the real merged file accepts >20,000 words (26,845).
- [x] Ordinary-word probes present: `học sinh`, `bánh mì`, `xe đạp`, `giáo viên`, `hoa
hồng`, `tình yêu`, `nước mắm`, `mặt trời`, `trái đất`. `con người` and `cánh diều` are
not in the 2018 branch at all — expected, recorded.
- [x] `--sources wiktionary` (default) and `--sources hongocduc,wiktionary` both build, with
the first strictly smaller (26,845 vs 61,271 with no case rule).
- [x] No word in the output is reachable only through `hongocduc` or `tudientv`.
## Risk Assessment
**A JSON schema surprise.** The file is 8 years old and unversioned; a row could carry an
unexpected shape. Signal: decode errors on the real file. Response: the error names the
line — inspect it, and only then decide between a tolerant skip with a counted reason and
a hard failure. Silent skipping is not an option. (Observed: none; all 79,226 rows decode.)
@@ -1,87 +0,0 @@
---
phase: 2
title: "Phase 2: Pin the source"
status: done
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 2: Pin the source
## Overview
Point the build machinery at the new file: pinned by commit and checksum, fetched by
`make fetch-dict` and by the Docker dictionary stage, with the existing agreement test
still guarding both.
## Requirements
- Functional: `make fetch-dict` downloads the pinned JSONL and verifies its SHA-256;
`make dict` builds `data/noitu.db` from it.
- Functional: the Docker `dict` stage does the same, and its fixture escape hatch
(`FIXTURE_DICT=1`) keeps working untouched.
- Functional: the fetched source file is gitignored — we distribute the derived database,
never a copy of their file.
- Non-functional: `DICT_URL` / `DICT_SHA256` keep their names so
`web/tests/dictionary-source.test.js` continues to guard Makefile/Dockerfile agreement
with no change to the test.
## Architecture
The pin is a commit-addressed raw URL, which is immutable — unlike `master`, where the
URL and the checksum could silently diverge:
```make
DICT_URL := https://raw.githubusercontent.com/undertheseanlp/dictionary/2c078cfc373b06e2980d324ce1d7bd13740c3319/dictionary/words.txt
DICT_SHA256 := 4c3e0e6117e4bdfa97731e135c3d4a05881889909267394a8de8d88ef79f13f0
DICT_SRC := data/undertheseanlp-words.jsonl
```
`fetch-dict` and `verify-dict` keep their current shape — `curl` then `sha256sum -c` —
because that is already the right shape; only the size in the help text changes.
## Related Code Files
- Modify: `Makefile` — `DICT_URL`, `DICT_SHA256`, `DICT_SRC`, the `dict` target's flags
(`--merged` in place of `--in`), and the `help` text's "~179 MB"
- Modify: `Dockerfile` — `ARG DICT_URL`, `ARG DICT_SHA256`, the `curl`/`sha256sum` lines,
the `build-dictionary` invocation, and the `FIXTURE_DICT` comment's 179 MB reference
- Modify: `.gitignore` — ignore `data/undertheseanlp-words.jsonl` (`data/*.db` already
covers the derived and old source databases)
- Verify unchanged: `web/tests/dictionary-source.test.js`, `.github/workflows/ci.yml`
## Implementation Steps
1. Update the three Makefile variables and the `dict` target to pass `--merged $(DICT_SRC)`.
`--sources` is left at its `wiktionary` default so the Makefile and Dockerfile carry
one fewer thing to keep in agreement.
2. Reword `help`, `fetch-dict` and the `$(DICT_SRC)` guard: the download is ~4.8 MB now,
and saying "179 MB" would be the kind of stale comment that outlives three refactors.
3. Mirror all of it in the Dockerfile stage. Keep `FIXTURE_DICT=1` on `--words` — the
image smoke test must not start needing a network.
4. Add the source file to `.gitignore` and confirm `git status` is clean after a fetch.
5. Run `make fetch-dict && make verify-dict && make dict` end to end from a clean `data/`.
6. Run `npm test` in `web/` — `dictionary-source.test.js` should pass untouched. If it
needs editing, the variable names were changed unnecessarily; put them back.
7. Build the image with and without `FIXTURE_DICT=1` and confirm both produce a database.
## Success Criteria
- [ ] `make fetch-dict` pulls 4,813,111 bytes and the checksum verifies.
- [ ] `make dict` produces `data/noitu.db` with the Phase 1 word count.
- [ ] `web/tests/dictionary-source.test.js` passes with no edits to it.
- [ ] `docker build` succeeds both with `FIXTURE_DICT=1` and without.
- [ ] `git status` is clean after a fetch — no source file staged, ever.
- [ ] No file in the repository still claims a 179 MB dictionary download.
## Risk Assessment
**`raw.githubusercontent.com` is not an archive.** An 8-year-dormant repository could be
deleted or renamed, and then `make fetch-dict` fails for everyone. Signal: a 404 on fetch.
Response: the checksum makes any mirror verifiable, so host a copy as a release asset on
this repository and re-pin to it. Worth doing pre-emptively if the build ever gates CI.
**The Docker stage and the Makefile drift.** Signal: the agreement test fails. Response:
that is the test doing its job — fix the file that is behind, and do not weaken the test to
match.
@@ -1,110 +0,0 @@
---
phase: 3
title: "Phase 3: Audit the corpus"
status: done
priority: P1
effort: "2h"
dependencies: [1, 2]
---
# Phase 3: Audit the corpus
> **Outcome.** Executed 2026-09-08; see [`audit-proper-noun-drops.md`](./audit-proper-noun-drops.md).
> The drop rule passed its sample gate (1 common word in 100) but cost 215 words under
> wiktionary-only case evidence, including everyday vocabulary. Narrowing was measured and
> rejected. **The owner abandoned the rule**: every word is kept regardless of case. Final
> corpus 26,845 words; playability and bot results recorded in the audit note.
## Overview
The gate. A rule that removes thousands of words on a capitalization heuristic has to be
looked at before it ships. This phase samples what was dropped, measures what the corpus
gained and lost, and confirms the game graph is not worse.
## Requirements
- Functional: a hand audit of 100 randomly sampled proper-noun drops, recorded in the
plan directory with the verdict per entry.
- Functional: a measured diff against the current `data/noitu.db` — gained, lost, and the
reason for each loss bucket.
- Functional: a playability comparison on the graph the game actually walks.
- Non-functional: every number in this phase is reproducible from a command written down
in the audit note, not from a one-off shell line nobody kept.
## Architecture
Three measurements, each against the freshly built database and the current one:
1. **Drop audit.** Sample 100 of the dropped keys. Each is *proper noun* (correct),
*common word* (a false positive, the thing we are looking for), or *unclear*. The rule
passes at ≤2 common words in 100; that tolerance is the difference between a filter and
a corpus edit.
2. **Corpus diff.** Overlap, gained, lost. Split the lost into: dropped as proper nouns,
absent from the wiktionary branch. The report measured 4,335 / 22,651 of 26,986 lost;
this re-measures from the shipped build. Separately count the **271 case casualties**
— drops that have a lowercase form in `hongocduc` or `tudientv` — and list them in
full; that list is what the pass/narrow/exception decision is made on.
3. **Playability.** Words, syllables, syllables that can open a word, how many have ≥2
continuations, and dead-end syllables — the last of these being what decides whether
the game hands somebody an unanswerable position. Current: 48,216 / 6,676 / 5,049 /
3,682 / 1,627. Expected: 22,310 / 5,484 / 4,050 / 2,686 / 1,434. The corpus is smaller
by design, so the question is not "is it bigger" but "does the game still run": add
bot-versus-bot game lengths from `server/internal/bot`'s real-corpus tests on both
databases.
## Related Code Files
- Create: `plans/260908-1525-dictionary-corpus-switch/audit-proper-noun-drops.md` —
the sample, the verdicts, the commands that produced them
- Read only: `data/noitu.db` (current, before regeneration), the newly built database
## Implementation Steps
1. Build the new database to a scratch path, leaving the shipped one in place for
comparison. Do not overwrite `data/noitu.db` in this phase.
2. Emit the dropped keys from the builder — a `--report-drops <file>` flag, or a temporary
local patch if the flag would otherwise have no user. Prefer the flag: the next person
to change this rule will want it too.
3. Sample 100 with a fixed seed, write them into the audit note, and judge each one by
hand. Vietnamese place and person names are the expected content; anything that reads
as an ordinary word is a false positive and gets called one.
4. Run the corpus diff and the playability comparison; put both tables in the audit note.
5. Build `--sources hongocduc,wiktionary` to a second scratch path purely to enumerate the
case casualties (words dropped under `wiktionary` alone but kept when hongocduc's
lowercase forms are visible). Nothing from that build ships.
6. Decide: pass, narrow, exception list, or abandon the rule. Record the decision and its
reason in the audit note — and if the rule changes, Phase 1's tests change with it.
## Success Criteria
- [ ] The audit note exists, with 100 judged entries and the commands that produced them.
- [ ] ≤2 of 100 sampled drops are ordinary words, the 271 known case casualties aside —
those are listed in full and judged as a group.
- [ ] The new corpus has >20,000 words.
- [ ] The five graph numbers and bot-game lengths are recorded for both databases; bot
games on the new graph complete without the engine running out of moves earlier
than on today's.
- [ ] The loss buckets are quantified, not estimated.
- [ ] A pass/narrow/exception-list/abandon decision is written down with its reason.
## Risk Assessment
**The audit passes on a sample and the corpus is still wrong in the tail.** 100 of several
thousand is a sample, not a proof. Signal: players disputing rejected words after release.
Response: the drop list is a file — a disputed word can be checked against it in seconds,
and a per-word exception list is a small change on top of this design.
**The audit fails and the phase becomes a redesign.** Signal: >2 common words in 100.
Response: apply Phase 1's pre-decided narrowing (every syllable capitalized) or the
exception list, re-audit once. If it fails again, ship without the drop — the proper-noun
problem then stays exactly as bad as it is today rather than getting worse.
**The corpus is too thin in play.** 22,310 words is less than half of today's. Signal: bot
games noticeably shorter, or the dead-end rate per move up rather than down. Response: this
is the trigger for the documented upgrade path — the 2026-09-01 viwiktionary dump (31,637
words, same license) as a second `--words` source — not for re-admitting GPL data.
**Playability regresses in a way these five numbers do not capture.** Signal: aggregate
counts look fine but bot games end oddly short or long. Response: `server/internal/bot`'s
real-corpus tests run bot-versus-bot games on the shipped database; run them before
accepting the phase, since they exercise the graph rather than counting it.
@@ -1,116 +0,0 @@
---
phase: 4
title: "Phase 4: Update the attribution"
status: done
priority: P1
effort: "1h"
dependencies: [3]
---
# Phase 4: Update the attribution
> **Outcome.** Done 2026-09-08. `data/LICENSE` untouched; `NOTICE` section 2 names the new
> upstream, `data/ATTRIBUTION.md` rewritten (eight modifications, both attribution links,
> both exclusions). Also updated, beyond the plan's list: the frontend attribution footer
> (`AttributionFooter.svelte`, `i18n/vi.js`) and its e2e assertion now credit Wiktionary
> tiếng Việt instead of minhqnd — the user-visible half of the CC BY-SA obligation.
## Overview
The data license does not change — the wiktionary branch is CC BY-SA 4.0, the same license
`noitu.db` already carries — but everything that names the source does. This phase rewrites
the attribution and notice text and regenerates the shipped database in one commit, so the
attribution never describes a database other than the one in the tree.
## Requirements
- Functional: `data/LICENSE` is untouched — still the CC BY-SA 4.0 text.
- Functional: `NOTICE` section 2 names the new upstream, keeps CC BY-SA 4.0, and keeps
section 1 (Apache-2.0 for code) byte-identical.
- Functional: `data/ATTRIBUTION.md` records the new source, the branch actually used, the
two branches excluded and why, and the full list of modifications.
- Functional: the regenerated `data/noitu.db` carries matching `meta` provenance.
- Non-functional: no overclaiming. The branch is a 2018 scrape of vi.wiktionary.org made by
a third party; say that, and attribute Wiktionary's contributors as CC BY-SA requires.
## Architecture
The dual-license structure in `NOTICE` is correct and stays; only the source lines of its
second section change:
```
1. SOURCE CODE — Apache-2.0 (unchanged: server/, tools/, web/, proto/)
2. DICTIONARY DATA — CC BY-SA 4.0 (unchanged license, new upstream)
data/noitu.db, data/LICENSE, data/ATTRIBUTION.md
```
The attribution chain is now two links instead of an opaque aggregate: vi.wiktionary.org
contributors (CC BY-SA) → `undertheseanlp/dictionary`, branch data `wiktionary`, commit
`2c078cf` → this project. Both links are named.
The modifications list stays at eight: source selection replaces the old language filter
as entry 1, which the license requires us to declare as a change.
## Related Code Files
- Modify: `NOTICE` — section 2's upstream line, affected-artifacts line (`dictionary.db`
is no longer the source), and the closing paragraph about build artifacts
- Modify: `data/ATTRIBUTION.md` — source table, branch selection, exclusions, modifications
9 and 10, the reproduce block
- Modify: `README.md` — dictionary-source lines (`~179 MB`, `dictionary.db`, `minhqnd`,
the manual `curl` + `--in` example)
- Modify: `docs/deployment.md` — the 179 MB builder-stage sentence
- Verify unchanged: `data/LICENSE`, `.github/workflows/ci.yml` required-files check
- Regenerate: `data/noitu.db`
## Implementation Steps
1. Rewrite `NOTICE` section 2's source lines: upstream `undertheseanlp/dictionary` at
commit `2c078cf`, wiktionary data only; affected artifact `data/noitu.db`. Leave the
share-alike paragraph as is — it is still true.
2. Rewrite `data/ATTRIBUTION.md`:
- source is `undertheseanlp/dictionary`, file `dictionary/words.txt`, commit `2c078cf`
- rows used: those tagged `wiktionary` — a 2018-12-10 scrape of vi.wiktionary.org,
whose text is CC BY-SA 4.0 / GFDL by its contributors
- `hongocduc` **excluded**: GNU GPL per the branch README; using it would relicense this
database to GPLv3
- `tudientv` **excluded**: its README declares copyright *"Chưa rõ"* and names
Soha/Vietlex (Hoàng Phê) as its primary source. Say it plainly so nobody reopens it.
- modifications renumbered: **1. source selection** replaces the language filter (the new
file is Vietnamese-only); the other seven carry over, with normalization stating that
capitalization removes nothing — the proper-noun drop was abandoned at the Phase 3 gate
- reproduce block: `make fetch-dict` is ~4.8 MB now
3. Grep the repository for `minhqnd`, `dictionary.db`, `179` and `--in` and fix every
survivor outside `plans/` — the reports are dated records and stay as written.
4. Regenerate `data/noitu.db` and confirm its `meta` rows agree with `ATTRIBUTION.md`:
source URL, commit, license, `sources_kept = wiktionary`,
`sources_excluded = hongocduc,tudientv`.
5. Run the CI required-files check locally, or read it and confirm by hand.
## Success Criteria
- [ ] `data/LICENSE` has no diff.
- [ ] `NOTICE` section 2 names the new upstream and still says CC BY-SA 4.0; section 1 is
byte-identical.
- [ ] `data/ATTRIBUTION.md` lists eight modifications, names the branch used and the two
excluded with reasons, and credits vi.wiktionary.org contributors.
- [ ] `meta` in the built database matches the attribution file.
- [ ] No file outside `plans/` names minhqnd, `dictionary.db` as a source, or a 179 MB
download.
- [ ] CI's required-files check passes.
## Risk Assessment
**A stale source claim survives somewhere.** A README line or Docker comment naming the old
upstream is an attribution misstatement, not a typo. Signal: the grep in step 3 finds
something after the phase is called done. Response: grep is a step in the phase for that
reason; run it last, not first.
**Section 1 gets damaged while section 2 is edited.** The code's Apache-2.0 grant is not
in scope and must come out byte-identical. Signal: a diff touching section 1. Response:
review the `NOTICE` diff before committing.
**The attribution credits the wrong party.** CC BY-SA attribution belongs to Wiktionary's
contributors; undertheseanlp is the intermediary that scraped and redistributed. Signal:
an `ATTRIBUTION.md` that names only the GitHub repository. Response: step 2 names both
links of the chain explicitly.
@@ -1,96 +0,0 @@
---
phase: 5
title: "Phase 5: Retire the SQLite path"
status: done
priority: P2
effort: "1h"
dependencies: [4]
---
# Phase 5: Retire the SQLite path
> **Outcome.** Done 2026-09-08. `--in`, `--table`, `--word-col`, `--lang-col`, `--lang`,
> `resolveSource`, `validateSource`, `listTables`, `listColumns`, `pickColumn`, `extract`
> and `quoteIdent` removed; `meta` no longer writes `source_word_column` /
> `source_lang_column` (no consumer existed). The four auto-detection tests went; the
> output-shape tests now build their fixture through `--merged`. `--help` lists `--merged`,
> `--sources`, `--words`, `--out`, `--max-syllables`, `--min-words` and nothing else.
## Overview
Delete the input mode nothing uses any more: reading a source SQLite database, with the
table and column auto-detection that existed because the upstream schema was not ours to
rely on. Two input modes for one source is dead weight, and dead weight in a build tool is
where stale assumptions hide.
## Requirements
- Functional: `--in`, `--table`, `--word-col`, `--lang-col` and `--lang` are gone, along
with the schema resolution behind them.
- Functional: `--merged` (the corpus) and `--words` (the fixture) remain. The fixture path
stays because the e2e suite and the Docker smoke build depend on it.
- Non-functional: the package doc comment describes what the tool now does, not what it
used to.
- Non-functional: `modernc.org/sqlite` stays a dependency — it writes the output database.
## Architecture
What `run()` dispatches on collapses from three modes to two:
```
--merged <file> the corpus: undertheseanlp JSONL (default path)
--words <file> the fixture: one word per line (e2e, Docker smoke build)
```
`resolveSource` and its auto-detection tests go with `--in`. They were never about the
game's rules — they were insurance against an upstream schema we did not control, and we
no longer read that upstream.
## Related Code Files
- Modify: `server/cmd/build-dictionary/main.go` — remove `--in` and the four schema flags,
`resolveSource`, the SQLite read path, and the `config` fields behind them; rewrite the
package doc comment and the usage example
- Modify: `server/cmd/build-dictionary/main_test.go` — remove the source-resolution tests;
keep and extend the ones about output shape
- Modify: `Makefile` — drop any remaining `--in` reference and 179 MB wording from `help`
- Verify: `Dockerfile` no longer mentions `--in`
## Implementation Steps
1. Remove the flags, the `config` fields, `resolveSource` and the SQLite read path.
2. Delete the tests that exist only to cover auto-detection. Do not delete tests covering
output shape, `verify()`, or the reject-reason counts — those still describe behavior.
3. Rewrite the package doc comment: the input is a 4.8 MB JSONL wordlist with source
membership and capitalization, of which only wiktionary-tagged rows are read; the
output is the game's syllable-indexed database.
4. `go build ./... && go vet ./... && go test ./...` from `server/`.
5. Grep for `--in`, `dictionary.db`, `resolveSource` and `lang_code` outside `plans/`;
nothing should survive except in the dated reports.
6. `make dict` once more from a clean `data/` to prove the surviving path is the one that
works.
## Success Criteria
- [ ] `go build`, `go vet` and `go test ./...` are clean from `server/`.
- [ ] `build-dictionary --help` lists `--merged` and `--words` and nothing about tables,
columns or language codes.
- [ ] `make dict` and `make fixture-dict` both work.
- [ ] The e2e suite still builds its fixture and passes.
- [ ] No reference to `--in`, `resolveSource` or `lang_code` survives outside `plans/`.
## Risk Assessment
**Deleting the wrong tests.** The auto-detection tests and the output-shape tests live in
the same file. Signal: coverage of `verify()` or the reject counters disappears with the
deletion. Response: read `main_test.go` in full before cutting, and delete by test
function rather than by line range.
**Somebody wants the SQLite path back.** A future source could ship SQLite again. Signal:
a future plan proposing it. Response: git remembers, and re-adding a reader for a schema
we would then actually control is a smaller job than maintaining auto-detection for a
schema nobody reads. Deleting it is the cheaper bet.
**The fixture path is quietly coupled to something removed.** Signal: `make fixture-dict`
or the e2e build fails after the cut. Response: step 6 builds both paths; run it before
calling the phase done, not after.
@@ -1,155 +0,0 @@
---
title: "Dictionary corpus switch"
description: "Replace the 179 MB minhqnd aggregate with the wiktionary branch of undertheseanlp/dictionary only (hongocduc and tudientv excluded) and keep data/noitu.db on CC BY-SA 4.0"
status: completed
priority: P1
effort: "~1d"
tags: [dictionary, data, licensing, build]
created: 2026-09-08
updated: 2026-09-08
blockedBy: []
blocks: []
---
# Dictionary corpus switch
## Overview
`data/noitu.db` was derived from `minhqnd/dictionary` v2.0.0: a 179 MB SQLite aggregate of
five upstreams, redistributed as CC BY-SA 4.0. Its license chain does not close — three of
its five inputs are GPL (FVDP, tudientv, "Vietnamese Explanatory Dictionary") and GPL does
not permit relicensing to CC BY-SA.
This plan switches the corpus to the **`wiktionary` source inside `undertheseanlp/dictionary`**
— a single 4.8 MB JSONL file carrying, per word, which of three source dictionaries contain
it. Only rows whose `source` list contains `wiktionary` are read. `hongocduc` (GPL) is
excluded so the data license does not change; `tudientv` is excluded because its own README
declares copyright *"Chưa rõ"* and names Soha/Vietlex (Hoàng Phê) as its primary source.
`data/noitu.db` **stays CC BY-SA 4.0**: the wiktionary branch is a 2018-12-10 scrape of
vi.wiktionary.org, whose text is CC BY-SA.
Evidence, all measured through the real `build-dictionary` filter:
`plans/reports/research-260908-1507-undertheseanlp-dictionary.md` (branch licensing),
`plans/reports/research-260908-1529-viwiktionary-dump-measured.md` (yields, alternatives)
and [`audit-proper-noun-drops.md`](./audit-proper-noun-drops.md) (the Phase 3 gate).
## What changes, in numbers
| | before | after |
|---|---|---|
| words | 48,216 | **26,845** |
| syllables | 6,676 | 5,709 |
| syllables that can open a word | 5,049 | 4,158 |
| …with ≥2 continuations | 3,682 | 2,787 |
| dead-end syllables | 1,627 | 1,551 |
| source download | 179 MB SQLite | 4.8 MB JSONL |
| data license | CC BY-SA 4.0 | **CC BY-SA 4.0 (unchanged)** |
| `--min-words` floor | 40,000 | **20,000** |
**This is a deliberate 44% cut.** 1,280 words gained, 22,651 lost — every one of them
simply absent from a 2018 wiktionary scrape (`bánh đúc`, `lò vi sóng`, `dân làng`, `thót
tim`). Bot-versus-bot games run about a quarter shorter on the thinner graph (easy-vs-easy
12.9 moves vs 17.5). This loss is accepted here; the upgrade path is written down below.
## Decisions taken
- **Only `wiktionary` rows are read.** `hongocduc` and `tudientv` inform nothing. That is
what keeps the license CC BY-SA 4.0 and what makes "we do not touch that data" true
without qualification.
- **Capitalization is not a filter.** The original design dropped words that appear only
ever capitalized, as proper nouns. It was built, run and audited at the Phase 3 gate: the
rule was precise on a 100-word sample (1 common word) but, with wiktionary-only case
evidence, it also removed 215 words including `mặt trời`, `trái đất`, `tổ quốc`, `dường
như`, `phật giáo`. Narrowing was measured and did not help. **The owner chose to keep
every word regardless of case.** Proper nouns like `hà nội` remain playable, exactly as
they are today. The mechanism was removed from the builder rather than left behind a flag.
- **Rejected alternatives, so nobody reopens them without new evidence:**
- `hongocduc + wiktionary` (61,271 words) — forces `noitu.db` to GPLv3. Rejected to keep
CC BY-SA 4.0.
- the 2026-09-01 viwiktionary dump (36,200 words without a case rule, same license,
monthly pin) — the 2018 branch is a near-subset of it. Rejected for now in favour of the
simpler single-file pin; **this is the documented upgrade path** if the 27k corpus
proves too thin in play. See the 15:29 report.
- kaikki.org enwiktionary Vietnamese — unpinnable rolling file, deprecated by its host.
- **The SQLite input path is deleted, not kept "just in case."** The fixture path
(`--words`) stays because the e2e suite and the Docker smoke build use it.
- **The build floor drops to 20,000.** The old 40,000 default would reject the new corpus;
it moved once, in `main.go`.
## The pin
```
URL https://raw.githubusercontent.com/undertheseanlp/dictionary/2c078cfc373b06e2980d324ce1d7bd13740c3319/dictionary/words.txt
SHA256 4c3e0e6117e4bdfa97731e135c3d4a05881889909267394a8de8d88ef79f13f0
Size 4,813,111 bytes — 79,226 JSONL rows, 32,484 of them tagged wiktionary
Commit 2c078cf (2018-12-10, the repository's last)
```
Pinned by commit rather than branch: `raw.githubusercontent.com/<repo>/<sha>/` is
immutable, so the URL and the checksum cannot disagree later. The variable names
`DICT_URL` / `DICT_SHA256` were kept so `web/tests/dictionary-source.test.js` needed no
change — only their values moved.
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Read the merged list](./phase-01-start.md) | Done |
| 2 | [Pin the source](./phase-02-pin-the-source.md) | Done |
| 3 | [Audit the corpus](./phase-03-audit-the-corpus.md) | Done — gate sent the case rule back; rule abandoned |
| 4 | [Update the attribution](./phase-04-update-the-attribution.md) | Done |
| 5 | [Retire the SQLite path](./phase-05-retire-the-sqlite-path.md) | Done |
All five phases are uncommitted in one working tree; they should land together so the
attribution never describes a database other than the one the build produces.
## Success criteria
- [x] `make fetch-dict && make dict` builds `data/noitu.db` from a 4.8 MB pinned file with
no 179 MB download.
- [x] The shipped database has **more than 20,000 words** and **no word that reaches it
through `hongocduc` or `tudientv` alone**.
- [x] `meta` records the source URL, commit, sources kept and sources excluded.
- [x] Playability is measured and recorded: syllables, openers, continuations, dead ends,
and bot-versus-bot game lengths on the new graph versus the old.
- [x] `data/LICENSE` is unchanged; `NOTICE` and `data/ATTRIBUTION.md` name the new source,
its commit and license, and record why the two other branches are excluded. The
frontend attribution footer credits Wiktionary tiếng Việt.
- [x] `go vet ./... && go test ./...` (incl. `-race` on the touched packages), `npm run
check && npm test`, and the Docker image (`noitu:ci` with the CI file checks, plus
`FIXTURE_DICT=1`) are green; the fixture build path is untouched.
- [ ] The Playwright e2e suite is green. **Not verified locally**: Playwright's Chromium
download timed out twice on this machine (2026-09-08), so every test failed at browser
launch, not on game behaviour. The one e2e assertion this plan changes (the attribution
footer's link text and href in `web/e2e/bot-game.spec.js`) was updated by hand. Verify
in CI or after `npx playwright install chromium` succeeds.
- [x] `--in` and its schema auto-detection are gone, and no file outside `plans/` still
mentions minhqnd, `dictionary.db`, `--in` or a 179 MB download.
## Licence version — decided after review
The owner asked whether the data could move to Apache-2.0 to simplify the repo. It cannot:
the words are Wiktionary contributors' text under a share-alike licence, and neither
undertheseanlp nor this project can relicense them. The owner then chose to **keep the
licence the source text actually carried, CC BY-SA 3.0 Unported**, rather than upgrade the
derivative to 4.0 under the later-version clause. `data/LICENSE` is now the BY-SA 3.0 legal
code; `NOTICE`, `ATTRIBUTION.md`, README, Dockerfile, deployment doc, the builder's meta
string and the in-game footer all say 3.0. The "unchanged" wording above predates this.
## Review
Code review (2026-09-08) returned DONE_WITH_CONCERNS; every finding was applied: the
builder's copy of the upstream commit is now guarded by `web/tests/dictionary-source.test.js`
alongside the Makefile/Dockerfile pin; fixture builds record "no upstream data" instead of
inheriting the CC BY-SA string; `ATTRIBUTION.md` states the 2018 snapshot was CC BY-SA 3.0
and why the derived database is 4.0; dead `contains()`, redundant sorts, the off-by-one
scanner line number, stale "48k" comments, the Makefile `curl` flags and the Dockerfile
download name were fixed; input-selection and fixture-licence tests were added.
## Open questions
- Is 26,845 words enough for the game to feel playable? Bot games are ~25% shorter; only
real play can say whether that is felt. If not, the 2026 viwiktionary dump is the next
step, not a return to GPL data.
<!-- slug: dictionary-corpus-switch -->
@@ -1,97 +0,0 @@
# Measurement: first kaikki build vs the undertheseanlp corpus
Date: 2026-09-08. Phase 3 of `plan.md`. The pre-switch `data/noitu.db` (26,845 words) was
copied aside before the first kaikki build and is the "old" side of every table.
## The file measured
```
source_url https://kaikki.org/viwiktionary/Ti%E1%BA%BFng%20Vi%E1%BB%87t/kaikki.org-dictionary-Ti%E1%BA%BFngVi%E1%BB%87t.jsonl
source_sha256 51ddc2fbd73cd7e2a468ccff3c31200928e7200cc58622f528ba3eb84c7a49f0
source_rows 44564
source_fetched_at 2026-09-08T10:04:08Z (kaikki's export of 2026-09-06, dump 2026-09-01)
size 62,288,004 bytes; longest line 61,552 bytes
```
Build log:
```
rejected 4: contains a digit
rejected 136: contains punctuation
rejected 8348: fewer than 2 syllables
rejected 160: no Vietnamese letters
parts of speech: noun 16219, verb 9355, adj 6698, name 5180, unknown 4652, adv 1024,
phrase 392, proverb 322, character 198, intj 175, pron 100, conj 70, prep 48, num 45,
abbrev 38, particle 18, romanization 14, prefix 13, suffix 2, det 1
accepted 34813 distinct words
generated 1664 spelling aliases (552 skipped as ambiguous or already real words)
```
## Commands
```sh
cp data/noitu.db /tmp/old.db # before the switch
make fetch-dict && make dict # or the raw curl + go run lines in README
cd server && go test ./internal/bot/ -run RealCorpus -v -count=1 # once per database at data/noitu.db
# graph numbers and diff: sqlite, see the queries below
```
```sql
SELECT COUNT(*) FROM words;
SELECT COUNT(*) FROM syllables;
SELECT COUNT(DISTINCT first) FROM words;
SELECT COUNT(*) FROM (SELECT first FROM words GROUP BY first HAVING COUNT(*) >= 2);
SELECT COUNT(*) FROM syllables WHERE out_degree = 0;
```
## Playability
| metric | old (undertheseanlp) | new (kaikki) |
|---|---|---|
| words | 26,845 | **34,813** |
| syllables | 5,709 | 6,081 |
| syllables that open a word | 4,158 | 4,487 |
| …with ≥2 continuations | 2,787 | 3,163 |
| dead-end syllables | 1,551 | 1,594 (27.2% → 26.2% of syllables) |
Bot-versus-bot, 60 games each, same run:
| | old | new |
|---|---|---|
| hard-vs-easy win rate (moves) | 98% (3.6) | 97% (3.2) |
| medium-vs-easy | 87% | 95% |
| hard-vs-medium (moves) | 57% (3.1) | 67% (3.1) |
| easy-vs-easy game length | 14.4 moves | 14.4 moves |
| hard decision p95 | 1.5 ms | 3.1 ms |
Both real-corpus tests pass on both databases. Game length is unchanged; the stronger bots
win more often on the denser graph, which is the expected direction.
## Corpus diff
| | |
|---|---|
| shared | 25,392 |
| gained | **9,421** — `công nghiệp hóa`, `bóng râm`, `bội thu`, `đồng dao`, `bỏ ngỏ`, `đắt hàng`, `ấn độ giáo`, `chạy làng`, `béo nung núc`, `nhìn muốn rụng trứng` |
| lost | **1,453** — see below |
### The 1,453 lost words
Sample of 25 (seed 7): `trịnh tuệ`, `tuân khanh`, `qua quít`, `cúc pha`, `mành mành`,
`cây bài chặt`, `trảm phong`, `kéo cánh`, `ngọt lự`, `rưng rức`, `làu nhàu`, `trong lúc`,
`khí hư`, `trần ích tắc`, `nháo nhác`, `trần cảnh`, `óc ách`, `lườm lườm`, `hỗn luân`,
`trụi lủi`, `tục tác`, `lảu nhảu`, `phát sầu`, `hãng hàng không quốc gia việt nam`, `trói ké`.
Two kinds. Roughly a fifth are historical person names (`trần ích tắc`, `trần cảnh`, `trịnh
tuệ`) whose pages have since been deleted or moved. The rest are real vocabulary, heavily
reduplicatives (`rưng rức`, `nháo nhác`, `óc ách`, `làu nhàu`, `lườm lườm`), that the 2018
scrape had and the current wiktextract export does not. Most likely those pages still exist
but in a markup the `vi` extractor does not yet parse — the raw 2026 dump has 36,200
words to kaikki's 34,813, a 1,387-word gap of the same order. **Not acted on:** the plan
does not merge sources, and the net is +7,968 words. If the reduplicatives matter, the
option is a second `--words` input reduced from the raw dump, as the previous plan's
research described.
## Verdict
Every number above today's; nothing regresses. Corpus accepted as built.
@@ -1,118 +0,0 @@
---
phase: 1
title: "Phase 1: Read the kaikki file"
status: completed
priority: P1
effort: "3h"
dependencies: []
---
# Phase 1: Read the kaikki file
## Overview
Replace the undertheseanlp reader in `build-dictionary` with one for kaikki's wiktextract
JSONL, hashing the input as it streams so the database records exactly which bytes it was
built from.
## Requirements
- Functional: `--kaikki <file>` reads one JSON object per line, keeps rows whose
`lang_code` is `vi`, and feeds `word` to the existing `accept()`.
- Functional: rows with any other `lang_code` are counted and rejected, not silently
skipped — the file is vi-only today, and a change in that would be worth seeing in the log.
- Functional: `pos` is tallied per value and printed with the reject counts. It never filters.
- Functional: the SHA-256 of the file and the row count are computed during the single
streaming pass and written to `meta` as `source_sha256` and `source_rows`, with
`source_fetched_at` taken from the file's modification time.
- Functional: `--merged` and `--sources` are removed along with `merged_list.go` and its
tests. Exactly one of `--kaikki` / `--words` must be given, same rule as today.
- Functional: the `--min-words` default moves from 20,000 to 30,000.
- Non-functional: a malformed line is an error naming the line, as today. Lines can be
long — a row carries every sense and translation — so the scanner buffer must allow
several megabytes; measure the longest line on the real file and set the cap above it
with headroom, or switch to `bufio.Reader.ReadBytes('\n')` which has no cap.
- Non-functional: `finish()`, `accept()`, aliases, `write`, `verify` unchanged.
## Architecture
```
--kaikki <file>
│ one line at a time, bytes also fed to sha256.New()
▼
json.Unmarshal → {word, lang_code, pos}
│
├─ lang_code != "vi" → rejects["not Vietnamese-language entry"]++
▼
accept(word) (NFC, lowercase, ≥2 syllables, alphabet, phonotactics)
▼
finish() (floor 30,000, aliases, atomic write, verify)
```
Decode only the three fields via a struct; `encoding/json` ignores the rest, so the 62 MB
of senses and translations cost I/O but not memory.
`sourceSpec.extra` already exists for mode-specific meta rows; the kaikki provenance uses it:
```
source_url the kaikki URL (constant kaikkiSourceURL)
source_sha256 hex of the streamed bytes
source_rows lines decoded
source_fetched_at file mtime, RFC 3339 UTC
source_license CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/)
```
## Related Code Files
- Create: `server/cmd/build-dictionary/kaikki_list.go`
- Create: `server/cmd/build-dictionary/kaikki_list_test.go`
- Delete: `server/cmd/build-dictionary/merged_list.go`, `merged_list_test.go`
- Modify: `server/cmd/build-dictionary/main.go` — flags, dispatch, doc comment, `--min-words`
default, `mergedProvenance` call site
- Modify: `server/cmd/build-dictionary/main_test.go` — `fixtureSource`/`defaultRows` emit
kaikki-shaped rows (`{"word": ..., "pos": ..., "lang_code": "vi"}`); the "excluded source"
row becomes a `lang_code: "en"` row
## Implementation Steps
1. Write `kaikki_list.go`: `kaikkiRow{Word, Pos, LangCode}`, `readKaikkiList(path, maxSyllables)`
returning words, rejects, a `pos` tally, and a `kaikkiProvenance` (sha256, rows, mtime).
Wrap the file in `io.TeeReader` into `sha256.New()` so hashing is free.
2. Add `rejectNotVietnamese rejectReason = "not a Vietnamese-language entry"` to `filter.go`.
3. In `main.go`: replace `merged`/`sources` with `kaikki` in `config`, flags and `run()`;
`--min-words` default 30,000; `runFromKaikkiList` logs rejects, the POS tally
(sorted, one line) and the accepted count, then calls `finish()` with the provenance spec.
4. Delete the merged reader and its tests. Rewrite `main_test.go` fixtures to kaikki rows.
5. Tests in `kaikki_list_test.go`: a `vi` row kept; an `en` row rejected and counted;
`Hà Nội` kept as `hà nội`; a word listed twice with different `pos` kept once; a malformed
line names its number; `meta` carries a 64-hex `source_sha256` equal to `sha256sum` of the
fixture file, the right `source_rows`, and no `source_commit`/`sources_*` keys.
6. Run against the real file (`scratchpad/kk-vi-edition.jsonl` from this session, or a fresh
download) and record the counts in phase 3.
## Success Criteria
- [x] `go test ./cmd/build-dictionary/` passes; `merged_list*.go` no longer exist.
- [x] Real file builds >30,000 words (measured 34,813) and logs a POS tally.
- [x] `build-dictionary --help` lists `--kaikki`, `--words`, `--out`, `--max-syllables`,
`--min-words` and nothing else.
- [x] `meta.source_sha256` of a build equals `sha256sum` of the input file.
- [x] The fixture path (`--words ... --min-words 150`) is byte-for-byte unaffected in
behaviour: same 205 words from `testdata/fixture-words.txt`.
## Risk Assessment
**A row exceeds the scanner buffer.** Signal: `bufio.Scanner: token too long` on the real
file. Response: pre-decided — use `bufio.Reader` with no line cap rather than guessing a
buffer size; measure the longest line once and note it in the phase result.
**kaikki changes the row shape.** `word` and `lang_code` are wiktextract's stable core fields
and unlikely to move. Signal: zero accepted words → the 30,000 floor fails the build with a
clear message. Response: read the new shape, adjust the struct; the failure mode is loud by
design.
**The file arrives partially.** `curl -f` catches HTTP errors but not a truncated body, and
a body cut exactly on a line boundary parses cleanly. Signal: a malformed final line (error
names it) or a word count under the 30,000 floor (13.8% headroom on 34,813). Response: the
fetch writes to a `.part` name and renames only on success (review finding, Phase 2), so an
interrupted download is never left for the next `make dict`; the floor covers the rest.
@@ -1,105 +0,0 @@
---
phase: 2
title: "Phase 2: Fetch the newest file"
status: completed
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 2: Fetch the newest file
## Overview
Point the Makefile and the Docker dictionary stage at kaikki's rolling URL with no checksum,
keep the two build files in agreement, and make sure a failed download cannot pass as a
success anywhere.
## Requirements
- Functional: `make fetch-dict` downloads the kaikki file to `data/kaikki-viwiktionary-vi.jsonl`
with `curl -fL` and fails on any HTTP error; `make dict` builds from it with `--kaikki`.
- Functional: `verify-dict` and `DICT_SHA256` are removed from the Makefile and Dockerfile;
nothing in the repository claims a checksum for this file.
- Functional: the Docker `dict` stage downloads the same URL and runs the same command;
`FIXTURE_DICT=1` keeps working untouched.
- Functional: `web/tests/dictionary-source.test.js` asserts the Makefile and Dockerfile URLs
agree, that the URL is the kaikki viwiktionary `Tiếng Việt` file, and that the builder's
`kaikkiSourceURL` constant is the same URL. The three checksum assertions go.
- Functional: the CI leak guard greps for `kaikki.*\.jsonl$`; `.gitignore` ignores the new
file name.
- Non-functional: no resume flag (`-C -`) — a rolling file can change between attempts, and a
resumed download would splice two versions together.
## Architecture
```make
DICT_URL := https://kaikki.org/viwiktionary/Ti%E1%BA%BFng%20Vi%E1%BB%87t/kaikki.org-dictionary-Ti%E1%BA%BFngVi%E1%BB%87t.jsonl
DICT_SRC := data/kaikki-viwiktionary-vi.jsonl
DICT_OUT := data/noitu.db
fetch-dict:
@mkdir -p data
curl -fL -o $(DICT_SRC).part $(DICT_URL) && mv $(DICT_SRC).part $(DICT_SRC)
dict: $(DICT_SRC)
cd server && go run ./cmd/build-dictionary --kaikki ../$(DICT_SRC) --out ../$(DICT_OUT)
```
The Dockerfile mirrors it with `ARG DICT_URL` only. The URL must stay percent-encoded in
both files: Make and `sh` would otherwise split on the space in `Tiếng Việt`.
Correctness now rests on three checks instead of a checksum: `curl -f` plus an atomic
`.part` rename (HTTP errors and interrupted downloads), the JSON decoder (shape, and
truncation that lands mid-line), the 30,000 floor (content, including a truncation that
lands on a line boundary).
## Related Code Files
- Modify: `Makefile` — variables, `help` text, `fetch-dict`, remove `verify-dict` and its
`.PHONY` entry, `dict` flags
- Modify: `Dockerfile` — `ARG DICT_URL`, remove `ARG DICT_SHA256` and the `sha256sum` line,
download file name, `--kaikki`; stage comment (no longer "pinned … checked by digest")
- Modify: `web/tests/dictionary-source.test.js` — drop checksum tests, keep URL agreement,
add the kaikki-path and builder-constant assertions
- Modify: `.github/workflows/ci.yml` — leak-guard pattern and comment
- Modify: `.gitignore` — `data/kaikki-viwiktionary-vi.jsonl` replaces the undertheseanlp entry
- Modify: `README.md` — Make targets table (`verify-dict` row gone), the manual `curl` and
`build-dictionary` lines
## Implementation Steps
1. Update the Makefile variables and targets; delete `verify-dict`.
2. Mirror in the Dockerfile; rewrite the stage comment to say the file is fetched fresh and
identified by the SHA-256 the builder records.
3. Rewrite the pin test as described. Run `npx vitest run tests/dictionary-source.test.js`.
4. Update the CI leak guard, `.gitignore` and the README lines.
5. Run `make fetch-dict && make dict` (or the raw commands on Windows) from a clean `data/`.
6. Build the image with and without `FIXTURE_DICT=1`; run the CI file checks against the
real one and confirm no `*.jsonl` is inside.
7. Simulate a bad download once: point `DICT_URL` at a 404 and confirm `curl -f` fails the
target; feed the builder a truncated copy of the file and confirm the build fails.
## Success Criteria
- [x] `make fetch-dict` fetches ~62 MB and `make dict` builds >30,000 words.
- [x] `grep -rn DICT_SHA256 --exclude-dir=plans .` finds nothing; `make verify-dict` is not a
target.
- [x] `web/tests/dictionary-source.test.js` passes and fails if either URL is edited alone.
- [x] Both image variants build; the real one passes the CI file checks; no `.jsonl` inside.
- [x] A 404 URL and a truncated file each fail the build with a readable message.
## Risk Assessment
**kaikki.org is a single volunteer-run host.** An outage breaks `make fetch-dict` and the
release image build until it returns. Signal: `curl: (22)` on fetch. Response: wait, or
build from a previously fetched local copy (`make dict` needs only the file); if outages
recur, keep a copy as a release asset and point `DICT_URL` at it — the open question in
`plan.md`.
**Two builds ship different words.** By design. Signal: none needed. Response: `meta`
identifies the bytes; the audit note in phase 3 records what this first build contained.
**The space in the URL.** Signal: `curl` fetching `https://kaikki.org/viwiktionary/Ti%E1%BA%BFng`
and failing. Response: the URL stays percent-encoded in every file, and the pin test checks
that the string contains `Ti%E1%BA%BFng%20Vi%E1%BB%87t`.
@@ -1,81 +0,0 @@
---
phase: 3
title: "Phase 3: Measure the corpus"
status: completed
priority: P1
effort: "1h"
dependencies: [1, 2]
---
# Phase 3: Measure the corpus
## Overview
Record what the first kaikki build contains against today's database: the graph numbers, the
words gained and lost, the POS mix, and bot-versus-bot behaviour. Measurement, not a gate —
no rule removes words any more, so there is nothing to audit by hand.
## Requirements
- Functional: a note in this plan directory with the numbers below and the commands that
produced them, plus the `source_sha256` of the file measured so the note is tied to bytes.
- Functional: a diff against the current `data/noitu.db` (copied aside before Phase 2
overwrites it): shared, gained, lost, with 25 random samples of each.
- Functional: playability on both databases: words, syllables, openers, openers with ≥2
continuations, dead ends, and the `internal/bot` real-corpus test output.
- Non-functional: every number reproducible from a written command.
## Architecture
Pre-measured this session on kaikki's 2026-09-06 file (sha256 `51ddc2fbd73cd7e2…`); the
phase re-measures on whatever the build fetched and explains any delta:
| | today | kaikki-vi (2026-09-06) |
|---|---|---|
| words | 26,845 | 34,813 |
| syllables | 5,709 | 6,081 |
| openers ≥2 continuations | 2,787 | 3,163 |
| dead-end syllables | 1,551 | 1,594 |
| shared / gained / lost | | 25,392 / 9,421 / 1,453 |
POS of the 41,507 distinct words: noun 16,219 · verb 9,355 · adj 6,698 · name 5,180 ·
unknown 4,652 · adv 1,024 · phrase 392 · proverb 322 · character 198. `name` and `unknown`
are kept; the log prints the tally so a future decision has the numbers.
## Related Code Files
- Create: `plans/260908-1653-kaikki-viwiktionary-corpus/measurement.md`
- Read only: the pre-switch `data/noitu.db` copy, the new build, `server/internal/bot`
real-corpus tests
## Implementation Steps
1. Before Phase 2 overwrites it, copy today's `data/noitu.db` to a scratch path.
2. After the first `make dict`, record `meta.source_sha256`, `source_rows`,
`source_fetched_at` and the build log's reject and POS lines.
3. Compute the diff and the five graph numbers for both databases (sqlite, one script; keep
the script text in the note).
4. Run `go test ./internal/bot/ -run RealCorpus -v` against both databases (swap the file
under `data/noitu.db`, restore afterwards) and record the game-length and win-rate lines.
5. Look at the 1,453 lost words: they are words in the 2018 scrape that Wiktionary has since
deleted or renamed. Sample 25, note what they look like (expected: misspellings, moved
pages, deleted junk). No action unless the sample is mostly real vocabulary.
## Success Criteria
- [x] `measurement.md` exists with the tables, samples, bot lines, commands and the source
SHA-256.
- [x] Words > 30,000; syllables, openers and ≥2-continuation counts all above today's.
- [x] Bot real-corpus tests pass on the new database; easy-vs-easy game length is not
shorter than today's 12.9 moves.
- [x] The lost-word sample is characterised in one paragraph.
## Risk Assessment
**The fetched file differs from the one measured today.** Certain over time. Signal: counts
off from the table above. Response: record the new numbers and the SHA-256; the deltas are
the point of the note, not a failure.
**Dead ends rise slightly (1,551 → 1,594).** More words bring more rare final syllables.
Signal: already known. Response: the ratio of dead ends to syllables falls (27.2% → 26.2%),
so the graph is denser, not sparser; record it and move on.
@@ -1,94 +0,0 @@
---
phase: 4
title: "Phase 4: Relicense to 4.0"
status: completed
priority: P1
effort: "1h"
dependencies: [3]
---
# Phase 4: Relicense to 4.0
## Overview
Current Wiktionary text is CC BY-SA 4.0, so the derived database is too. Restore the 4.0
legal code, rewrite the attribution chain for a source with no commit to cite, and update
every place that says 3.0 — in the same commit as the first kaikki-built database.
## Requirements
- Functional: `data/LICENSE` is the CC BY-SA 4.0 legal code, byte-identical to the file this
repository shipped before commit `39ef457` (`git show e19b083:data/LICENSE`).
- Functional: `NOTICE` section 2 says 4.0 and names Wiktionary tiếng Việt as the original
work and kaikki.org/wiktextract as the extraction; section 1 byte-identical.
- Functional: `data/ATTRIBUTION.md` records the source URL, that the file is refreshed
weekly and unpinned, that the exact bytes of any build are identified by
`meta.source_sha256`, the three attribution links, and the modifications list.
- Functional: the in-game footer links to the 4.0 deed and says 4.0; the e2e assertion
matches.
- Non-functional: no overclaiming about dates — the attribution names the dump date only as
"the Wikimedia dump kaikki extracted at fetch time; see `source_fetched_at`", not a fixed
date that goes stale.
## Architecture
Attribution chain, all three named:
```
Wiktionary tiếng Việt contributors authors, CC BY-SA 4.0
→ wiktextract / kaikki.org (Tatu Ylonen) extraction; cite LREC 2022 as kaikki requests
→ this project filter + index, data/noitu.db, CC BY-SA 4.0
```
Modifications list (renumbered): 1 language selection (`lang_code = vi`), 2 length filter,
3 content filter, 4 normalization, 5 spelling aliases, 6 added columns and tables,
7 deduplication, 8 dropped fields (senses, translations, POS, categories — everything but
the word form).
## Related Code Files
- Modify: `data/LICENSE` — restore 4.0 text from git history
- Modify: `NOTICE` — section 2 heading, source lines, share-alike sentence
- Modify: `data/ATTRIBUTION.md` — full rewrite of Source, Modifications 1 and 8, reproduce
block (no checksum step)
- Modify: `README.md` — licence table row, dictionary-source paragraph
- Modify: `Dockerfile`, `docs/deployment.md`, `.github/workflows/ci.yml` — 3.0 → 4.0 in comments
- Modify: `server/cmd/build-dictionary/main.go` doc comment, `server/cmd/noitu-server/main.go`
and `server/internal/dictionary/store.go` comments, `store_test.go` seed strings
- Modify: `web/src/lib/components/AttributionFooter.svelte` (deed URL, comment),
`web/src/lib/i18n/vi.js` (`attributionLicense`), `web/e2e/bot-game.spec.js` (regex)
- Verify unchanged: `.github/workflows/ci.yml` required-files check
## Implementation Steps
1. `git show e19b083:data/LICENSE > data/LICENSE`; confirm the first lines read
"Attribution-ShareAlike 4.0 International".
2. Rewrite `NOTICE` section 2 source lines; leave the share-alike paragraph's meaning, change
the version.
3. Rewrite `data/ATTRIBUTION.md` as described. State plainly: "The file is not pinned. Each
build fetches kaikki's current export; the SHA-256 and row count of the file a given
database was built from are recorded in its `meta` table."
4. `grep -rn "3\.0\|by-sa/3" --exclude-dir=plans .` and fix every hit; then the same for
`undertheseanlp`, `DICT_SHA256`, `--merged`, `--sources`.
5. Regenerate `data/noitu.db`; confirm `meta.source_license` says 4.0 and matches the files.
6. Run the full verification set from the previous plan: Go vet/test, web check/test, both
image builds with the CI file checks.
## Success Criteria
- [x] `data/LICENSE` is the 4.0 text; `git diff e19b083 -- data/LICENSE` is empty.
- [x] No file outside `plans/` says CC BY-SA 3.0 or names undertheseanlp.
- [x] `NOTICE` section 1 has no diff.
- [x] `ATTRIBUTION.md` names all three links and states the unpinned-fetch policy.
- [x] `meta.source_license` in the built database says 4.0.
- [x] All verification suites green; the commit contains both the licence files and the
builder change so no intermediate tree mixes 3.0 text with 4.0 data.
## Risk Assessment
**A stale 3.0 or undertheseanlp claim survives.** Signal: the grep in step 4 finds one after
the phase is called done. Response: grep is a step for that reason; run it last.
**The footer wording.** `giấy phép CC BY-SA 3.0` → `4.0` is one string, but the e2e regex
and the deed URL must change with it. Signal: e2e footer test fails in CI. Response: all
three edits are listed above; do them together.
@@ -1,142 +0,0 @@
---
title: "Kaikki viwiktionary corpus"
description: "Replace the 2018 undertheseanlp wiktionary rows with kaikki.org's current extraction of Wiktionary tiếng Việt, fetched unpinned at build time, and move the data licence to CC BY-SA 4.0"
status: completed
priority: P1
effort: "~1d"
tags: [dictionary, data, licensing, build]
created: 2026-09-08
blockedBy: []
blocks: []
---
# Kaikki viwiktionary corpus
## Overview
`data/noitu.db` is built from the `wiktionary` rows of `undertheseanlp/dictionary`: a
2018-12-10 scrape of vi.wiktionary.org, 26,845 words after filtering, pinned by commit and
SHA-256 (plan `260908-1525-dictionary-corpus-switch`, completed today). It is the same
website eight years staler than it needs to be.
This plan switches the source to **kaikki.org's extraction of Wiktionary tiếng Việt** — the
`Tiếng Việt` language file of the `viwiktionary` edition, produced by wiktextract from the
monthly Wikimedia dump (currently 2026-09-01) and refreshed roughly weekly. Same authors,
same website, current text. Measured through the real `build-dictionary` filter this
session: **34,813 words**, a near-superset of today's corpus (25,392 shared, 9,421 gained,
1,453 lost) and 96% of the raw dump (36,200).
Two owner decisions shape the plan:
- **viwiktionary only.** kaikki's English-Wiktionary Vietnamese file (21,244 words, marked
deprecated by kaikki) is not used. The game is Vietnamese; one edition, one attribution.
- **No pin — fetch the newest file every build.** kaikki publishes rolling files with no
archived snapshots, so a checksum pin would break weekly. The owner chose freshness over
reproducibility. The build therefore has to *fail loudly* on a bad download (HTTP error,
malformed JSON, too few words) and *record what it actually got* (SHA-256 and row count in
`meta`) so any shipped database can still be traced to exact bytes.
Consequence: the current text of Wiktionary is CC BY-SA **4.0** (Wikimedia moved from 3.0
on 2023-06-29), so `data/noitu.db` returns to 4.0. The 3.0 chosen this afternoon only ever
applied to the 2018 snapshot.
Evidence: `plans/reports/research-260908-1529-viwiktionary-dump-measured.md` (dump and
kaikki-en measurements) plus this session's measurement of the kaikki-vi file, recorded in
[phase 3](./phase-03-measure-the-corpus.md).
## What changes, in numbers
| | today | after |
|---|---|---|
| words | 26,845 | **34,813** (30,231 if `name` POS were dropped — it is not) |
| syllables | 5,709 | 6,081 |
| syllables with ≥2 continuations | 2,787 | 3,163 |
| dead-end syllables | 1,551 | 1,594 |
| source | 4.8 MB JSONL, 2018, pinned | **62 MB JSONL, current, unpinned** |
| data licence | CC BY-SA 3.0 | **CC BY-SA 4.0** |
| `--min-words` floor | 20,000 | **30,000** |
| build reproducible | yes | **no** — traceable via `meta.source_sha256` |
## The source
```
URL https://kaikki.org/viwiktionary/Tiếng Việt/kaikki.org-dictionary-TiếngViệt.jsonl
(percent-encoded: /viwiktionary/Ti%E1%BA%BFng%20Vi%E1%BB%87t/kaikki.org-dictionary-Ti%E1%BA%BFngVi%E1%BB%87t.jsonl)
Size 62,288,004 bytes on 2026-09-06 — 44,564 rows, 41,507 distinct words, all lang_code "vi"
Row {"word": "trở thành", "pos": "verb", "lang_code": "vi", "lang": "Tiếng Việt", "senses": [...], ...}
```
Only `word` and `lang_code` are read. `pos` is counted for the build log but never filters:
the owner's earlier decision that capitalization does not remove words carries over to the
`name` POS (5,138 name-only words). 5,475 words carry uppercase and 229 lowercase forms
occur twice; `accept()` lowercases and dedupes as it always has.
## Decisions taken
- **One corpus input mode.** `--merged`/`--sources` (undertheseanlp) is replaced by
`--kaikki`, not kept beside it. Two readers for one database is dead weight, as the SQLite
path was. The fixture path `--words` stays.
- **The floor moves to 30,000.** 34,813 measured; a truncated or reshaped download must
fail, and 30,000 is the highest round number with a comfortable margin.
- **Provenance is measured, not asserted.** The reader hashes the file as it streams it and
writes `source_sha256`, `source_rows` and `source_fetched_at` into `meta`. There is no
commit to record, so this is what "which bytes" means from now on.
- **Attribution names three links.** Wiktionary tiếng Việt contributors (authors, CC BY-SA
4.0), wiktextract/kaikki.org by Tatu Ylonen (extraction; kaikki asks for the LREC 2022
citation), and this project. No GitHub intermediary any more.
- **`verify-dict` goes.** There is nothing to verify against. The
Makefile/Dockerfile agreement test keeps guarding the URL and drops the checksum clauses.
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Read the kaikki file](./phase-01-start.md) | Pending |
| 2 | [Fetch the newest file](./phase-02-fetch-the-newest-file.md) | Pending |
| 3 | [Measure the corpus](./phase-03-measure-the-corpus.md) | Pending |
| 4 | [Relicense to 4.0](./phase-04-relicense-to-4-0.md) | Pending |
Phase 4 lands in the same commit as the first kaikki-built `noitu.db`: a 4.0-derived
database in a tree whose `NOTICE` says 3.0 is the one ordering mistake with a legal
consequence. Phase 3 is measurement, not a gate — there is no drop rule left to audit.
## Success criteria
- [x] `make fetch-dict && make dict` downloads kaikki's current file and builds
`data/noitu.db` with **more than 30,000 words**; a truncated or non-JSON download fails
the build with a message naming the cause.
- [x] `meta` records `source_url`, `source_sha256`, `source_rows`, `source_fetched_at` and
`source_license = CC BY-SA 4.0`; no `source_commit`, `sources_kept` or
`sources_excluded` remain.
- [x] `data/LICENSE` is the CC BY-SA 4.0 legal code; `NOTICE`, `data/ATTRIBUTION.md`, README,
Dockerfile, deployment doc, the builder's licence string and the in-game footer all say
4.0 and credit Wiktionary tiếng Việt contributors and wiktextract/kaikki.org.
- [x] Playability measured and recorded against today's database, including bot game
lengths; nothing regresses below today's numbers.
- [x] `go vet ./... && go test ./...`, `npm run check && npm test`, the Docker image with the
CI file checks and `FIXTURE_DICT=1` are green. e2e: not runnable on this machine (Playwright Chromium download fails); the one changed assertion was updated by hand, CI verifies.
- [x] No file outside `plans/` names undertheseanlp, `--merged`, `--sources` or `DICT_SHA256`.
## Review
Code review (2026-09-08) returned DONE_WITH_CONCERNS; every finding was applied: `fetch-dict`
downloads to a `.part` name and renames on success so an interrupted fetch never feeds the
next `make dict`; a failed `Stat` is an error rather than a zero `source_fetched_at`;
`builder_version` bumped to 3 for the changed meta contract; `.gitignore` covers
`data/*.jsonl`; `ATTRIBUTION.md` states kaikki's redistribution terms; bare JSON literals
are malformed rather than "foreign-language"; the non-EOF read-error line number is exact;
the CI leak guard matches any `.jsonl`; byte-exact reader tests cover a missing final
newline, an HTML error page, a mid-line cut and a bare literal. The reviewer's residual
note stands: with no pin, the build trusts kaikki.org over TLS, and the recorded SHA-256 is
the compensating control; blast radius is game content only.
## Open questions
- kaikki's weekly refresh means two builds a week apart can differ. Accepted by the owner;
`source_sha256` in `meta` is the answer to "which words did this image ship". Should a
release ever need to be rebuilt bit-for-bit, the fallback is to keep the fetched file as a
release asset — a Makefile variable change, not a redesign.
- The `unknown` POS bucket (4,616 words such as `tình báo`, `trọc phú`, `hội quán`) is real
vocabulary the vi extractor could not label. Kept, like everything else.
<!-- slug: kaikki-viwiktionary-corpus -->
@@ -1,251 +0,0 @@
---
title: "Measurement: first dump-built database against the kaikki one"
date: 2026-09-08
plan: 260908-2056-wiktionary-dump-corpus-and-meanings
status: complete
---
# Measurement: the dump-built database
Both databases were built on 2026-09-08 on the same machine with the same `accept()` filter.
The kaikki one was built with the last kaikki-era builder (`builder_version` 4) from the
export kaikki served that afternoon; the dump one with this plan's builder (`builder_version`
5) from the `latest/` dump, which was the 2026-09-01 run.
## The file measured
| | |
|---|---|
| URL | `https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2` |
| Resolved to | the 2026-09-01 run (`source_fetched_at` 2026-09-01T11:52:49Z, the file's own mtime kept by `curl -R`) |
| Size | 63,513,513 bytes |
| SHA-256 | `ed66c932f535b0b362d1141c02273b02ed01c4101e856378bd15e88d277bb8d9` (matches the research report's hash of the dated file) |
| `source_pages` | 43,011 |
| Decompressed | 505,982,744 bytes |
## Build log
```
pages 391543, in the main namespace 349461, redirects skipped 3237, without a Vietnamese section 303213
Vietnamese sections: legacy {{-vie-}} 35884, new == {{langname|vi}} == 7127, pages with both 0, titles merged 131
parts of speech: noun 15890, verb 8983, adj 6285, place 3258, pr-noun 1666, n 987, adv 689, phrase 589, idiom 541, v 503, adjc 499, proverb 333, proper noun 159, interj 131, …
headings without a label: cụm động từ 2, giải thích 1, định nghĩa 1
codes that ended a legacy section, commonest: tyz 256, eng 152, mtq 147, nut 49, vi-m 47, nuo 39, tyj 29, kpm 16, mlc 14, tou 14, aav-qal 13, vie-m 12, jra 10, sqi 10, bdq 9
definitions kept 40937 (cut at 200 characters: 515), dropped as empty after stripping 271
templates dropped whole, commonest: alternative spelling of 77, vi-alternative spelling of 40, senseid 37, vie alternative spelling of 36, misspelling of 34, syn of 32, mention 18, en 13, zh 13, synonym of 11
rejected 4: contains a digit
rejected 155: contains punctuation
rejected 6359: fewer than 2 syllables
rejected 162: no Vietnamese letters
rejected 303213: not a Vietnamese-language entry
accepted 36200 distinct words, 35062 with a meaning, (43011 pages, sha256 ed66c932f535b0b362d1141c02273b02ed01c4101e856378bd15e88d277bb8d9) in 32s
generated 1736 spelling aliases (558 skipped as ambiguous or already real words)
wrote data/noitu.db
```
**Wall time: 21–32 s** for the whole build on a laptop, Go's pure-Go bzip2 included. The
two-minute risk did not materialise; the `bzip2 -dc` fallback was not needed.
The counters reconcile: 349,461 main-namespace pages − 3,237 redirects − 303,213 without a
Vietnamese section = 43,011 = `source_pages`. The definition counters are taken after
`accept()`, so they describe words that land: 40,937 definitions kept against 40,842 rows,
the difference being senses past the cap on the 131 merged title pairs. The "codes that
ended a legacy section" line lists only language codes (tyz, eng, mtq, nut, …), which is the
check that no heading code is missing from the maps and cutting sections short.
The legacy count (35,884) is one page short of the research report's 35,885 because one page
opens its Vietnamese section after a level-2 heading of another dialect and is read as that
one; the new-dialect count differs by one for the same reason (7,127 vs 7,128). "Pages with
both" is zero, so the merge path for mixed pages stays untested against real data.
## Graph metrics, old vs new
| metric | kaikki (v4) | dump (v5) | change |
|---|---|---|---|
| words | 34,813 | **36,200** | +1,387 |
| syllables | 6,081 | 6,172 | +91 |
| syllables that open a word | 4,487 | 4,600 | +113 |
| …with ≥ 2 continuations | 3,163 | 3,246 | +83 |
| dead-end syllables | 1,594 | 1,572 | −22 |
| aliases | 1,664 | 1,736 | +72 |
| database size on disk | 2.2 MB | 6.9 MB | meanings text |
No metric regresses.
## Corpus diff
| | |
|---|---|
| shared | **34,813** — every kaikki word is in the dump build |
| gained | 1,387 |
| lost | **0** |
The first build lost 15 words (`tây tạng`, `thượng hải`, `nam kinh`, …). All were pages that
run `{{-vie-}}{{-pron-}}{{vie-pron|…}}{{-place-}}` together on one line, which the scanner
read as no section at all. Fixed in phase 1 (headings are read from a line that starts with
`{{-`, several per line); the second build lost none.
Gained, 25 at random: nghiến ngấu, hồn ai nấy giữ, pháo đùng, trèo cao té đau, ngồi rồi, tối
như hũ nút, tiêu tức, kinh kỳ, rón rón, tự phê bình, vàng hương, trơ mắt ếch, thìa khóa, qua
cầu rút ván, tinh khí, rộng chân rộng cẳng, say lử cò bợ, miệng ăn, sảo thai, phỉ dạ, vắng
như chùa bà banh, cọc tìm trâu, óng a óng ánh, lồm lộp, phơi phóng. Reduplicatives and
idioms, as the research predicted.
## Bot vs bot, 60 games each
Run with `go test ./internal/bot/ -run RealCorpus -v -count=1` against each database copied
to `data/noitu.db`. (The kaikki database needed an empty `meanings` table and a
`meaning_count` row added to a scratch copy: the v5 store refuses a v4 file, by design.)
| | kaikki | dump |
|---|---|---|
| hard beats easy | 100% (3.4 moves) | 95% (3.5 moves) |
| medium beats easy | 88% | 82% |
| hard beats medium | 60% (2.9 moves) | 72% (4.1 moves) |
| easy vs easy, game length | 16.8 moves | 13.7 moves |
| hard decision latency | n=74 p95 2.6 ms max 3.4 ms | n=68 p95 2.5 ms max 4.0 ms |
Both pass the ladder test's thresholds. The differences are within what a 60-game sample
moves between runs; the corpus is 4% larger and the graph slightly better connected.
## Meanings
| | |
|---|---|
| `meaning_count` (senses) | 40,842 |
| `words_with_meaning` | 35,062 of 36,200 = **96.9%** (floor in `verify`: 60%) |
| senses per word | 1: 30,454 · 2: 3,773 · 3: 583 · 4: 167 · 5 (the cap): 85 |
| definitions cut at 200 characters | 587 |
| definitions dropped as empty after stripping | 942 |
| senses with an empty part-of-speech label | 4,849 = **11.9%** |
Labels, by sense: danh từ 14,471 · động từ 7,804 · tính từ 5,839 · (empty) 4,849 · địa danh
3,702 · danh từ riêng 1,859 · phó từ 758 · cụm từ 544 · thành ngữ 446 · tục ngữ 329 · thán từ
112 · đại từ 47 · liên từ 44 · số từ 17 · giới từ 11 · tiền tố 7 · trợ từ 3.
The empty share is above the plan's 10% target, but the unmapped-heading list has been worked
through: every code seen more than once is mapped (see below). What remains empty is the
research report's "no-pos" class — pages whose Vietnamese section has definitions under no
part-of-speech heading at all (`sun phát`, `kiến vàng`, `long đong` in the sample). Nothing
in the wikitext gives those a label.
**Headings without a label, by frequency:** cụm động từ 2, classifier 1, det 1, giải thích 1,
prop 1, reading 1, tiếng anh 1, tyj 1, định nghĩa 1, đồng nghĩa khác âm 1. Nothing worth a
map entry.
**Templates dropped whole, ten commonest:** `vi-han form of` 222, `vi-nom form of` 163,
`alternative spelling of` 96, `senseid` 79, `rfdef` 63, `abbreviation of` 48,
`vi-alternative spelling of` 45, `vie alternative spelling of` 44, `vie-han form of` 41,
`misspelling of` 36. The "form of" and "spelling of" family is the one worth teaching next:
a definition that is only `{{vi-alt sp|hoá thạch}}` strips to nothing today, and `hóa thạch`
is one of the 1,138 words without a meaning for that reason. `rfdef` is a request for a
definition and correctly yields none.
### Deviations from the plan's label map, decided in phase 1 from the frequency tables
- `pron` on this wiki is **pronunciation** (36,533 legacy headings, 5,501 new), not pronoun.
The pronoun code is `pronoun` (158 + 141), also `per-pronoun`. The plan's `pron → đại từ`
would have labelled 36,000 pronunciation sections as pronouns.
- The new dialect uses `{{section|code}}` (3,506 headings) as well as `{{ĐM|code}}`, and the
shorthand codes `n` (647 + 9,488 wiki-wide) and `v` (353 + 3,774) beside `noun`/`verb`.
- `{{-dfn-}}` (4,615) is a "definitions" heading placed *under* a part-of-speech heading, so
it is transparent to the label rather than a heading of its own.
- Bare headword lines `{{vi-noun}}`, `{{vie-noun}}`, `{{vi-pr-noun}}` set the label when
their code is a part of speech; `{{vi-pron}}`, `{{vi-etym-sino}}` and kin do not.
- Two definition-shaped templates beyond the plan's list were taught because they top the
definition-line template counts: `{{nhãn|…}}`/`{{context|…}}`/`{{term|…}}` as label
spellings, `{{n-g|…}}` as a non-gloss definition, and `{{see-entry|x}}`/`{{like-entry|x}}`
(13,122 + 1,038 definition lines wiki-wide) rendered as `Xem x`.
- Definition lines without a space after `#` (`#{{term|cái cân}} Đồ dùng…`) are accepted;
only `##`, `#:`, `#*`, `#;` are sub-items.
## The 50-sense sample
Drawn with `SELECT word, pos, gloss FROM meanings ORDER BY RANDOM() LIMIT 50` from the
first build and read by hand. Verdicts: **fine 43, terse 5, wrong 2.**
| # | word | pos | gloss | verdict |
|---|---|---|---|---|
| 1 | quý tộc | danh từ | Họ dòng sang. | terse |
| 2 | khuyến mại | động từ | Xúc tiến việc mua bán hàng hóa, cung ứng dịch vụ bằng cách dành cho khách hàng những lợi ích nhất định. | fine |
| 3 | bất đắc dĩ | tính từ | Không có sự lựa chọn khác. | fine |
| 4 | hắc điếm | danh từ | (từ cổ) Quán trọ, khách sạn, nơi tạm trú (có thể do kẻ xấu lập ra nhằm cướp của, giết người khi có dịp). | fine |
| 5 | đèn vách | danh từ | Đèn dầu hoả treo trên vách nhà. | fine |
| 6 | chuẩn cơm mẹ nấu | cụm từ | Món hợp khẩu vị, ăn mãi không ngán. | fine |
| 7 | tác ác | động từ | Làm việc ác. | fine |
| 8 | ỉ ê | tính từ | Từ gợi tả tiếng khóc nhỏ, dai dẳng và ỉ eo một cách khó chịu (thường nói về trẻ con) | fine |
| 9 | dơ bẩn | tính từ | (Phương ngữ) Xem nhơ bẩn | fine (cross-reference) |
| 10 | tốt bộ | | Chỉ đẹp có bề ngoài. | fine, no label |
| 11 | hãm hại | động từ | Làm hại, giết chết bằng thủ đoạn ám muội. | fine |
| 12 | thuần hậu | tính từ | Chất phác hiền hậu. | fine |
| 13 | phúc bạc | | Phúc mỏng, ít phúc (không phải bạc là trắng, dù tác giả có ý đối với chữ má đào). | fine, no label |
| 14 | chặt chẽ | tính từ | Có quan hệ khăng khít, gắn kết với nhau. | fine |
| 15 | long đong | | Vất vả, nay đây mai đó, hay gặp nhiều rủi ro. | fine, no label |
| 16 | hồng bì | danh từ | Thứ cây cùng họ với cam, quít, quả nhỏ, da vàng, có lông nhung, vị chua ngọt. | fine |
| 17 | thế chiến i | danh từ riêng | Xem Chiến tranh thế giới thứ nhất. | fine (cross-reference) |
| 18 | khuyên nhủ | động từ | Khuyên bảo ân cần. | fine |
| 19 | thường thới hậu a | địa danh | Một xã thuộc huyện Hồng Ngự, tỉnh Đồng Tháp, Việt Nam. | fine |
| 20 | gạo sen | danh từ | Hạt trắng hình hạt gạo, ở đầu nhị đực của hoa sen, dùng để ướp chè. | fine |
| 21 | kèn trống | danh từ | Kèn và trống thường sử dụng trong đám ma. | fine |
| 22 | lộng quyền | động từ | Làm việc vượt quá quyền hạn của mình, lấn cả quyền hạn của người cấp trên. | fine |
| 23 | quay đĩa | danh từ | (Khẩu ngữ) máy quay đĩa (nói tắt) | fine |
| 24 | tam giáo | danh từ | (Id.). Ba thứ đạo ở Trung Quốc thời trước. | terse (a source abbreviation survived) |
| 25 | điều dưỡng viên | danh từ | Người phụ trách công tác điều dưỡng, … cho đến phục hồi, trị… | fine (cut at 200) |
| 26 | bán tống bán tháo | cụm từ | (Khẩu ngữ) Xem bán đổ bán tháo | fine (cross-reference) |
| 27 | kiểu cách | danh từ | Kiểu mẫu và cách thức. | fine |
| 28 | hiểm ác | tính từ | Ác một cách ngấm ngầm. | fine |
| 29 | ca nô | danh từ | Thuyền máy cỡ nhỏ, mạn cao, có buồng máy, buồng lái, dùng chạy trên quãng đường ngắn. | fine |
| 30 | bút pháp | danh từ | (cũ) Phong cách viết chữ Hán. | fine |
| 31 | thuần việt | tính từ | Có nguồn gốc từ người Việt. | fine |
| 32 | sun phát | | Muối của a-xít sun-phu-rích. | fine, no label |
| 33 | kiến vàng | | Loài kiến nhỏ, màu vàng, đốt đau. | fine, no label |
| 34 | trinh phú | địa danh | Một xã thuộc huyện Kế Sách, tỉnh Sóc Trăng, Việt Nam. | fine |
| 35 | lạnh gáy | tính từ | Xem lạnh người. | fine (cross-reference) |
| 36 | giải phóng | động từ | Làm cho được tự do, cho thoát khỏi địa vị nô lệ hoặc tình trạng bị áp bức, kiềm chế, ràng buộc. | fine |
| 37 | đẹp duyên | động từ | (kiểu cách) Xem kết duyên | fine (cross-reference) |
| 38 | thanh đạm | tính từ | (Ẩm thực) Giản dị, không có những món cầu kì hoặc đắt tiền. | fine |
| 39 | suy tàn | động từ | Ở trạng thái suy yếu và tàn lụi, không còn sức sống. | fine |
| 40 | tràng thạch | danh từ | (Địa lý học). | **wrong** — the definition after the label was a template the stripper dropped |
| 41 | toàn bộ | danh từ | Tất cả các phần, các bộ phận của một chỉnh thể. | fine |
| 42 | bát tiên | danh từ riêng | Tám vị tiên là Hán Chung Ly, … hay được vẽ trên màn trướng. | fine |
| 43 | ba mũi giáp công | danh từ | Tiến công bằng ba hình thức kết hợp: quân sự, chính trị và binh vận. | fine |
| 44 | sở ước | | Điều mình mong được. | fine, no label |
| 45 | giao diện lập trình ứng dụng | danh từ | (programming) Đặc tả quy định cách … | terse (English label from a pasted template) |
| 46 | ngộ gió | | Bị cảm vì gặp gió. | fine, no label |
| 47 | ảo tung chảo | tính từ | Cảm giác khó tin. | terse |
| 48 | nhóm bếp | | Đốt lửa cho củi bắt đầu cháy trong bếp. | fine, no label |
| 49 | trần hy tăng | danh từ riêng | Xem Trần Bích San | fine (cross-reference) |
| 50 | cung ứng | động từ | Cung cấp đáp ứng nhu cầu, thường là của sản xuất, hoặc của hành khách. | fine |
Two wrong out of fifty, for different reasons; well under the one-in-five threshold that
would have sent the stripper back to phase 1. The one systematic gap is a label followed by
a template-only definition (#40), which leaves a bare `(label).` — a follow-up for the
stripper, template by template.
## Words without a meaning
1,138 words (3.1%). Three read at random:
- `hóa thạch` — one definition, `# {{vi-alt sp|hoá thạch}}`, an alternative-spelling
template the stripper does not know.
- `lóng lánh`, `khoái trá` — the page has only pronunciation, paronyms and a `{{-see-}}`
section with `{{like-entry|…}}` as a bullet, not a `#` line. No definition to carry.
## Verification run alongside this measurement
- `go vet ./... && go test ./... -race`: green.
- `npm run check && npm test`: green (183 tests).
- `npm run test:e2e` and the two Docker image variants: see the plan's session log.
## After code review
The review (`plans/reports/code-reviewer-260908-2210-wiktionary-dump-corpus-and-meanings.md`)
found no critical issue; its findings were fixed in the same working tree: the bot-game e2e
assertion now reads the drawn opening's sense from the fixture list instead of assuming
`danh từ`; the chain-row styling is scoped to the outer list so the sense list renders as a
numbered list; the stripper drops format characters (bidi overrides, zero-width spaces) as
well as controls; a pre-v5 database is refused with a message that says to rebuild; a lone
short template parameter is no longer mistaken for a language code; the definition counters
are taken after `accept()`; section-ending codes are tallied; the meanings lookup happens once
per move; `aria-controls` is set only while the panel exists. The numbers above are from the
build after those fixes.
@@ -1,192 +0,0 @@
---
phase: 1
title: "Phase 1: Read the dump"
status: completed
priority: P1
effort: "6h"
dependencies: []
---
# Phase 1: Read the dump
## Overview
Replace the kaikki JSONL reader in `build-dictionary` with a streaming reader for the
Wikimedia `pages-articles.xml.bz2` dump that yields, per Vietnamese-section page, the title
for `accept()` and the stripped definition lines for the meanings table.
## Requirements
- Functional: `--dump <file>` reads a bzip2-compressed MediaWiki XML export. Pages with
`ns != 0` or a `<redirect>` element are skipped and counted. Every other page's `<title>`
and latest `<revision><text>` are handed to the section scanner.
- Functional: the scanner finds the Vietnamese section in either dialect — `{{-vie-}}` or a
level-2 heading whose text is `{{langname|vi}}` — and stops at the section's end: for
legacy pages, the next `{{-xxx-}}` template whose code is not in the section-code set; for
new pages, the next level-2 heading. Pages with no Vietnamese section are counted, not
read. Pages with a section in *both* dialects are read once, first dialect wins, counted.
- Functional: the title goes to `accept()` unchanged from today. Part of speech labels seen in
the section (`{{-noun-}}`, `{{ĐM|noun}}`, `{{vi-noun}}` and kin, `pr-noun`/`place`
included) are tallied for the log and never filter.
- Functional: within the section, every line starting with `# ` (exactly one `#`, then a
space — not `#:`, `#*`, `##`) is a definition. It is stripped (see Architecture), capped at
200 runes with an ellipsis, and appended to the page's senses up to 5. Empty results are
dropped. Senses are kept in page order.
- Functional: each sense carries the part of speech of the heading it sits under, as a
Vietnamese label from a fixed map keyed by the heading code — the same codes in both
dialects (`{{-noun-}}`, `{{ĐM|noun}}`, `{{vi-noun}}` → `danh từ`). Starting map: `noun` danh
từ, `verb` động từ, `adj` tính từ, `adv` phó từ, `pr-noun` danh từ riêng, `place` địa danh,
`pron` đại từ, `num` số từ, `conj` liên từ, `prep` giới từ, `intj` thán từ, `part` trợ từ,
`phrase` cụm từ, `idiom` thành ngữ, `prov` tục ngữ, `abbr` viết tắt, `prefix` tiền tố,
`suffix` hậu tố, `char` chữ. A definition under a heading outside the map, or before any
heading, has an empty label and is kept; unmapped codes are counted for the log. Step 1's
frequency list decides what else the map needs. (Validation session 1.)
- Functional: two pages whose titles normalize to the same word (`Việt Nam` and `việt nam`
both being real pages) merge: the word is one entry, senses concatenate in page order and
the cap applies to the union.
- Functional: provenance is measured during the one streaming pass: SHA-256 of the compressed
bytes as read, `source_pages` (pages with a Vietnamese section, redirects excluded),
`source_fetched_at` from the file's modification time. `source_rows` is gone.
- Functional: the build log reports pages seen, ns0 pages, redirects skipped, pages with a
Vietnamese section per dialect, POS tally, definitions kept, definitions dropped as empty
after stripping, and the ten commonest template names dropped whole.
- Functional: `--kaikki` and `kaikki_list.go` and its tests are removed. Exactly one of
`--dump` / `--words` must be given, same rule as today.
- Non-functional: a stream that is not bzip2, XML that ends mid-page, or a `<text>` element
the decoder cannot read is an error naming the page title or byte offset. A count of
Vietnamese-section pages under 20,000 is an error naming the count, distinct from the word
floor, so a parser that silently misses a dialect is caught before `--min-words` is.
- Non-functional: memory is one page at a time. The XML decoder is used token by token; only
`title`, `ns`, `redirect` and `text` of the current page are held.
- Non-functional: wall time under two minutes on the real dump on a laptop. Measure and
record; see Risk.
## Architecture
```
--dump <file.xml.bz2>
│ os.Open → io.TeeReader(f, sha256) → bufio.Reader → compress/bzip2 → xml.NewDecoder
▼
for each <page>:
ns==0, no <redirect> ─no─► count, skip
section := vietnameseSection(text) // dialect-aware slice of the wikitext
section == "" ─yes─► count "no Vietnamese section", skip
word, syllables, reason, ok := accept(title)
senses := definitions(section) // []sense{pos, gloss}: stripped, ≤5, ≤200 runes each
pos tally from section headings
if ok: words[word] = entry{…}; meanings[word] = merge(meanings[word], senses)
```
`type sense struct { pos, gloss string }` lives in `wikitext.go`; `pos` is the mapped
Vietnamese label or empty.
Files:
```
server/cmd/build-dictionary/
dump.go readDump: bz2+xml streaming, page loop, provenance, dumpSourceURL const
wikitext.go vietnameseSection, definitions, stripWikitext, posLabels, posLabelMap
dump_test.go byte-exact fixtures: one legacy page, one new-dialect page, a redirect,
a foreign-only page, a truncated stream, a non-bzip2 file
wikitext_test.go table tests for the stripper and the section boundaries
```
The section-code set for legacy pages is the list of `{{-xxx-}}` codes that are *headings*
rather than language switches: `etym, pron, noun, verb, adj, adv, pr-noun, place, phrase,
idiom, prov, syn, synonym, ant, trans, ref, reference, see, der, info, num, pron, conj,
prep, intj, part, abbr, char, hanzi, nom, …`. Build it from the real dump in this phase:
extract every `{{-xxx-}}` code, list them by frequency, and classify by hand; the language
codes are 2–3 letters and the section codes are English abbreviations, so the split is
readable. Record the set in `wikitext.go` with the frequency it was seen at.
The stripper, in order:
1. Drop `<!-- … -->`, `<ref …>…</ref>`, `<ref … />`, any other tag pair or lone tag.
2. Templates by a depth counter over `{{`/`}}`. For each outermost template, split on `|`
outside nested braces and brackets, take the name:
- `label`, `lb`, `gloss`, `qualifier`, `q` → `(` join of positional params after a leading
`vi` `)`;
- `l`, `vi-l`, `w`, `m`, `link` → the last positional param (display text if any);
- `place` → positional params after `vi`, each with a leading `[a-z]/` removed, joined by
`, `;
- anything else → dropped, name counted.
3. Links: `[[Thể loại:…]]`/`[[Category:…]]` dropped; `[[a|b]]` → `b`; `[[a]]` → `a`;
external `[http… label]` → `label`.
4. `'''`, `''` removed. `&nbsp;` and the common entities decoded.
5. Control characters (`unicode.IsControl`) removed; whitespace collapsed; trimmed. A result
that is only punctuation is empty.
6. Longer than 200 runes: cut at the last space before 200 and append `…`.
## Related Code Files
- Create: `server/cmd/build-dictionary/dump.go`, `wikitext.go`, `dump_test.go`,
`wikitext_test.go`
- Delete: `server/cmd/build-dictionary/kaikki_list.go`, `kaikki_list_test.go`
- Modify: `server/cmd/build-dictionary/main.go` — package doc, `config.kaikki` → `dump`,
flag text, `run()` switch, `runFromKaikkiList` → `runFromDump`, `entry` gains nothing (the
meanings map travels beside `words` into `finish()` — phase 3 persists it),
`builderVer = "5"`
- Modify: `server/cmd/build-dictionary/main_test.go` — `fixtureSource` writes a small bzip2
XML dump instead of JSONL (Go has no bzip2 *writer*: commit a tiny `testdata/mini-dump.xml.bz2`
built once with `bzip2`, plus its uncompressed source beside it for review)
- Modify: `server/cmd/build-dictionary/filter.go` — `rejectNotVietnamese` comment now refers
to a page with no Vietnamese section; the reason string can stay
## Implementation Steps
1. Download the current dump once locally (phase 2's URL, `curl -fLR`). Write a throwaway
`go run` that streams it and prints every `{{-xxx-}}` code with counts and every level-2
heading text with counts. Build the section-code set and the dialect markers from that
output; keep the numbers in comments.
2. Write `wikitext.go`: `vietnameseSection`, `posLabels`, `definitions`, `stripWikitext`.
Table-test the stripper on the three samples in `plan.md` plus: nested templates, a
`<ref>` mid-sentence, a link with a category, a definition that is only `{{rfdef|vi}}`
(must be empty), a 300-rune definition (must end in `…` at a word boundary), a legacy
page whose senses sit under `{{-noun-}}` then `{{-verb-}}` (labels `danh từ`, `động từ` in
order), a new-dialect page with `=== {{ĐM|pr-noun}} ===` (label `danh từ riêng`), a
definition under an unmapped heading (empty label, sense kept).
3. Write `dump.go`: the streaming loop, provenance, counters, the error shapes listed under
Non-functional. Test against `testdata/mini-dump.xml.bz2` (six pages, both dialects, a
redirect, an English-only page) and byte-truncated copies of it.
4. Rewire `main.go`; delete the kaikki files; make `go vet ./... && go test ./cmd/...` green.
5. Run against the real dump with `--out /tmp/dump.db`. Read the log: pages per dialect should
be within a few percent of 35,885 / 7,128 for the 2026-09-01 dump; accepted words near
36,200. Record wall time. Keep this database for phase 6.
6. Sample 50 senses at random from the log (or from the phase-3 table) and read them. Fix any
stripper rule that is clearly wrong across many entries; note the rest for phase 6.
## Success Criteria
- [x] `go run ./cmd/build-dictionary --dump ../data/viwiktionary-latest-pages-articles.xml.bz2 --out /tmp/dump.db`
completes; the log shows ~43,000 Vietnamese-section pages, both dialects non-zero,
~36,200 accepted words, and a definitions-kept count above 30,000.
- [x] The stripper table tests pass on all listed shapes; `# {{rfdef|vi}}` yields no sense;
labels come out in Vietnamese for both dialects and empty for an unmapped heading.
- [x] On the real dump, senses with an empty label are under 10% of all senses, or the
unmapped-code list has been worked through.
- [x] A truncated dump, a non-bzip2 file and a mid-page cut each fail with a message naming
the cause; a fixture with 3 Vietnamese pages fails the 20,000-page check by name.
- [x] `grep -rn kaikki server/` finds nothing.
- [x] Wall time on the real dump recorded in the phase-6 measurement file.
## Risk Assessment
**Go's bzip2 is slow.** Signal: step 5 takes longer than two minutes. Response: keep the
reader on an `io.Reader`, add `--dump` acceptance of a plain `.xml` (sniff the two magic
bytes `BZ`), and have the Makefile pipe `bzip2 -dc` in; the Docker `dict` stage on alpine has
`bzip2`. Decide in this phase, not later.
**The section-code set is incomplete.** A legacy heading code missing from the set is read as
a language switch and truncates the Vietnamese section early: definitions after it are lost,
the word is not. Signal: the definitions-kept count is well below the page count, or the
sample in step 6 shows senses cut off. Response: the step-1 frequency list is the source of
truth; every code seen more than ~20 times must be classified.
**Definitions in a template the stripper does not know.** The whole sense is dropped. Signal:
"definitions dropped as empty" is large, or a common template name tops the dropped list.
Response: teach the stripper that template if it is a definition-shaped one (`place`,
`label` are the known cases); otherwise accept the loss and record it in phase 6.
**Both dialects on one page.** Rare; first dialect wins and the page is counted. Signal: the
counter is not small. Response: read both sections and merge senses — a small change to
`vietnameseSection` returning a slice.
@@ -1,104 +0,0 @@
---
phase: 2
title: "Phase 2: Fetch the dump"
status: completed
priority: P1
effort: "2h"
dependencies: [1]
---
# Phase 2: Fetch the dump
## Overview
Point the Makefile, the Docker `dict` stage, the pin test, the CI leak guard and the README's
raw commands at Wikimedia's rolling `latest` dump instead of kaikki's JSONL, keeping the
three URL copies in agreement and a failed download unable to pass as a success.
## Requirements
- Functional: `DICT_URL` is
`https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2`;
`DICT_SRC` is `data/viwiktionary-latest-pages-articles.xml.bz2`. `fetch-dict` uses
`curl -fLR` — `-R` keeps the server's modification time, which is the dump's generation
time and becomes `source_fetched_at` — to a `.part` name renamed on success, as today.
- Functional: `dict` runs `build-dictionary --dump ../$(DICT_SRC)`.
- Functional: the Dockerfile `dict` stage mirrors the URL and the flag; `FIXTURE_DICT=1` is
untouched. `curl` gains `-R` there too.
- Functional: `web/tests/dictionary-source.test.js` asserts the Makefile and Dockerfile URLs
agree, that the URL matches
`^https://dumps\.wikimedia\.org/viwiktionary/latest/viwiktionary-latest-pages-articles\.xml\.bz2$`,
that the builder's `dumpSourceURL` constant in `dump.go` is the same string, and that README
and `data/ATTRIBUTION.md` quote it.
- Functional: the CI leak guard rejects any `\.bz2$` or `\.xml$` inside the image; the
comment says the dump, not the export. `.gitignore` covers `data/*.bz2`, `data/*.bz2.part`,
`data/*.xml` (for the phase-1 fallback) and drops the `.jsonl` lines.
- Functional: README's "Without make" block, Make targets table and Setup section describe
a ~61 MB monthly dump; the `fetch-dict` help line says the same.
- Non-functional: still no resume flag. `latest` can be repointed between two attempts.
## Architecture
```make
DICT_URL := https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2
DICT_SRC := data/viwiktionary-latest-pages-articles.xml.bz2
fetch-dict:
@mkdir -p data
curl -fLR -o $(DICT_SRC).part $(DICT_URL) && mv $(DICT_SRC).part $(DICT_SRC)
dict: $(DICT_SRC)
cd server && go run ./cmd/build-dictionary --dump ../$(DICT_SRC) --out ../$(DICT_OUT)
```
Correctness rests on: `curl -f` plus the `.part` rename (HTTP errors, interrupted
downloads); the bzip2 decoder (a truncated stream errors at EOF); the XML decoder (a cut
mid-page); the 20,000-page check and the 30,000-word floor (content).
## Related Code Files
- Modify: `Makefile` — header comment, `DICT_URL`, `DICT_SRC`, `help`, `fetch-dict`, `dict`
- Modify: `Dockerfile` — `dict` stage comment, `ARG DICT_URL`, `curl -fsSLR`, file name,
`--dump`
- Modify: `web/tests/dictionary-source.test.js` — URL pattern, builder file and constant name
- Modify: `.github/workflows/ci.yml` — leak guard pattern and comment; the `image` job's
comment about "the real upstream release" still holds
- Modify: `.gitignore`
- Modify: `README.md` — Setup, Make targets, Without make, Architecture table's dictionary
row wording if it names the export
## Implementation Steps
1. Update the Makefile; run `make fetch-dict` on a clean `data/` and check the file's mtime is
the dump's, not now.
2. Mirror in the Dockerfile.
3. Rewrite the pin test; run `npx vitest run tests/dictionary-source.test.js`. Edit one URL
copy alone and confirm it fails.
4. Update the CI leak guard, `.gitignore`, README.
5. `make dict`; then `docker build .` and `docker build --build-arg FIXTURE_DICT=1 .`; run
the CI file checks against the real image and confirm no `.bz2`/`.xml` inside.
6. Point `DICT_URL` at a 404 once and confirm `fetch-dict` fails; feed `dict` a copy of the
dump cut at 40 MB and confirm the build fails naming the cause.
## Success Criteria
- [x] `make fetch-dict` downloads ~61 MB with the server's mtime; `make dict` builds >30,000
words with meanings.
- [x] `web/tests/dictionary-source.test.js` passes and fails when any one copy is edited.
- [x] Both image variants build; the real one passes the CI file checks; nothing matching
`\.bz2$|\.xml$` inside.
- [x] `grep -rniI kaikki --exclude-dir=plans .` finds nothing outside `plans/`.
- [x] A 404 URL and a truncated file each fail the build readably.
## Risk Assessment
**`latest/` mid-repoint.** Wikimedia updates the `latest` symlinks file by file as a run
completes. Fetching during the run can return the previous month's file, which is correct,
or in principle a 404 for a moment. Signal: `curl: (22)`. Response: retry; nothing to fix.
**Dump size grows.** Irrelevant to correctness; the README's "~61 MB" goes stale slowly.
Signal: none. Response: update the number when it is off by a quarter.
**The Docker build now decompresses bzip2 in Go inside alpine.** Same code, same speed as
locally. If phase 1 chose the `bzip2 -dc` fallback, the stage needs `apk add bzip2` and the
pipe; the leak guard's `.xml` clause is why an uncompressed intermediate is also checked.
@@ -1,114 +0,0 @@
---
phase: 3
title: "Phase 3: Meanings in the database"
status: completed
priority: P1
effort: "4h"
dependencies: [1]
---
# Phase 3: Meanings in the database
## Overview
Persist the senses phase 1 extracts in a `meanings` table, load them in `dictionary.Store`
behind a `Meanings(word)` method, and let the fixture word list carry meanings so tests and
the e2e suite have some.
## Requirements
- Functional: schema gains
```sql
CREATE TABLE meanings (
word TEXT NOT NULL,
ord INTEGER NOT NULL,
pos TEXT NOT NULL, -- Vietnamese part-of-speech label, '' when the heading was unmapped
gloss TEXT NOT NULL,
PRIMARY KEY (word, ord)
) WITHOUT ROWID;
```
`ord` is the 0-based sense order from the page. No foreign key pragma; `verify()` checks
the join instead, as it does for aliases. (Validation session 1: `pos` column.)
- Functional: `finish()` takes the meanings map beside `words`; `writeTo` inserts them in the
same transaction. `meta` gains `meaning_count` (rows) and `words_with_meaning`.
- Functional: `verify()` adds: no meaning row whose word is missing from `words`; no empty
gloss; no gloss over 200 runes; `words_with_meaning` is at least 60% of `word_count` for a
`--dump` build (fixture builds are exempt — the check is passed a flag, or keyed on the
source table prefix).
- Functional: `dictionary.Store` loads `meanings` into `map[string][]Sense` ordered by
`ord`, where `type Sense struct { Pos, Gloss string }` is exported from the `dictionary`
package, and exposes `Meanings(word string) []Sense` returning nil for a word with none.
`Resolve` first: the caller passes a canonical word, as it does for `FirstSyllable`.
Startup log gains the meanings count next to `words`.
- Functional: `--words` accepts an optional meaning column: `word<TAB>sense<TAB>sense…`,
where a sense is `pos|gloss` or just `gloss` (the pipe never survives stripping, so it is a
safe separator). Lines without a tab have no meaning. `testdata/fixture-words.txt` gains hand-written
meanings for the words the e2e suite plays (phase 5 says which) and for at least half the
list, so the fixture database exercises the same store paths as the real one.
- Non-functional: `Store` memory grows by the text size, a few MB. Startup time unchanged
in practice (one more ordered scan).
- Non-functional: `builder_version = "5"` (set in phase 1; this is the contract it names).
## Architecture
```
build-dictionary dictionary.Store
words map[string]entry ─┐ words map[string]wordInfo
meanings map[string][]sense ─┼─► sqlite ─► meanings map[string][]Sense
aliases map[string]string ─┘ Meanings(word) []Sense
```
The store's `validate()` already cross-checks `word_count`; add the same for
`meaning_count` so a database whose meanings table was truncated on disk is refused at
startup rather than served silently without meanings.
## Related Code Files
- Modify: `server/cmd/build-dictionary/main.go` — `finish`, `write`, `writeTo`, `verify`,
`runFromWordList` (tab-separated senses), `sourceSpec` unchanged
- Modify: `server/cmd/build-dictionary/main_test.go` — meanings written and verified; a
fixture list with tabs; a `verify` failure on an orphan meaning row
- Modify: `server/internal/dictionary/store.go` — `meanings` field, `loadMeanings`,
`Meanings()`, `validate` count check, `MeaningCount()`
- Modify: `server/internal/dictionary/store_test.go` — hand-built databases gain the table;
`Meanings` returns ordered senses and nil; a mismatched `meaning_count` is refused
- Modify: `server/cmd/noitu-server/main.go` — startup log line
- Modify: `testdata/fixture-words.txt` — meanings column; header comment documents the format
- Modify: `web/e2e/fixture-dictionary.js` — it reads the list one word per line
(`fixture-dictionary.js:15-19`); cut each line at the first tab before trimming, or the
e2e graph gains words that are really `word<TAB>sense` strings (validation session 1)
## Implementation Steps
1. Schema, insert, meta and `verify` in the builder; tests first for the orphan-row and
empty-gloss failures.
2. `runFromWordList` tab parsing; a test that a line with two tabs yields two senses in order,
`danh từ|…` splits into label and gloss, a cell without a pipe has an empty label, and a
line without a tab yields none. Update `fixture-dictionary.js` in the same step.
3. Store: load, method, validate; tests.
4. Fixture list: add meanings. Rebuild `data/fixture.db` (`make fixture-dict`) and start the
server against it; the log shows the count.
5. Rebuild the real database from phase 1's dump and confirm `verify` passes the 60% rule.
## Success Criteria
- [x] `go test ./cmd/build-dictionary ./internal/dictionary -race` green.
- [x] `sqlite3 data/noitu.db 'SELECT COUNT(*) FROM meanings'` is above 40,000 and
`words_with_meaning` above 60% of words (phase 6 records the actual).
- [x] `store.Meanings("học sinh")` on the real database returns a non-empty ordered slice
whose first sense has `Pos == "danh từ"`; on a word with no `#` line it returns nil.
- [x] `npm run test:e2e` still builds its word graph from the fixture list correctly (no
tab-carrying "words").
- [x] The fixture database has meanings for the e2e words and the server starts on it.
## Risk Assessment
**The 60% rule is a guess.** Research counted 5,764 of 43,013 pages with no POS marker, and
some of those have no `#` line either; the true coverage is unknown until phase 1 runs.
Signal: `verify` fails on the real dump with a coverage in the 50s. Response: measure, set
the floor a comfortable margin under the measurement, and record why in the code comment.
The rule exists to catch a stripper that suddenly returns nothing, not to demand quality.
**Fixture meanings drift from fixture words.** A word renamed in the list loses its meaning
silently. Signal: an e2e assertion on a meaning fails. Response: the builder's fixture path
logs words without meaning; keep the list short and hand-checked.
@@ -1,108 +0,0 @@
---
phase: 4
title: "Phase 4: Meanings on the wire"
status: completed
priority: P1
effort: "3h"
dependencies: [3]
---
# Phase 4: Meanings on the wire
## Overview
Carry a word's senses in the two messages that introduce a word to the client —
`PlayedWord` and `GameStarted` — regenerate the Go and JS types and the binary fixtures,
and have the room attach the senses where it already renders those messages.
## Requirements
- Functional: `proto/noitu/v1/game.proto` gains
```proto
// One definition of a word as Wiktionary gives it: the part of speech it
// sits under, in Vietnamese ("danh từ"), empty when the heading was not one
// the builder knows, and the definition stripped of markup at at most 200
// characters. Plain text both: the client renders them as text, never as
// markup.
message Sense {
string pos = 1;
string gloss = 2;
}
message PlayedWord {
…
// At most five. Empty when the dictionary has no definition for the word.
repeated Sense meanings = 7;
}
message GameStarted {
…
repeated Sense opening_meanings = 9;
}
```
(Validation session 1: `Sense` instead of a bare string, so the label is a field.)
- Functional: `wsapi.Dictionary` gains `Meanings(word string) []dictionary.Sense`.
`game.Dictionary` does not change; the engine never reads a meaning. `convert.go` gains
`Senses([]dictionary.Sense) []*noituv1.Sense`.
- Functional: `convert.PlayedWord` takes the senses (`PlayedWord(m game.Move, byMe bool,
meanings []dictionary.Sense)`) and its one caller, `sendTurnUpdate` (`room.go:903`, also
used by the resume path), passes `r.dict.Meanings(move.Word)`; `sendGameStarted` fills
`OpeningMeanings` from `r.dict.Meanings(r.opening)`. Both paths are per recipient already;
the lookup happens once per move, before the per-seat loop.
- Functional: the reconnect path (`GameStarted` + last `TurnUpdate`) carries meanings
without further change; a test asserts a resumed client sees the opening's and last word's
senses.
- Functional: `buf generate` regenerates `server/gen/` and `web/src/lib/proto/`;
`go test ./internal/wsapi -update` regenerates `proto/testdata/`; the JS decode test reads
the new field.
- Functional: the hand-built dictionaries in `wsapi` tests implement `Meanings`; at least
one returns senses so the assertion is not vacuous.
- Non-functional: frame size grows by up to ~1 KB per move. Nothing in the client or server
caps frames near that.
## Architecture
```
room.handleSubmit ── engine.Submit ──► Move ──► meanings := r.dict.Meanings(move.Word)
for each seat: PlayedWord(move, byMe, meanings)
room.sendGameStarted ─────────────────────────► OpeningMeanings: r.dict.Meanings(r.opening)
```
The bot room path renders through the same `sendTurnUpdate`, so bot moves carry meanings
without a second change.
## Related Code Files
- Modify: `proto/noitu/v1/game.proto`
- Regenerate: `server/gen/noitu/v1/game.pb.go`, `web/src/lib/proto/**`, `proto/testdata/*`
- Modify: `server/internal/wsapi/room.go` — `Dictionary` interface, `sendGameStarted`,
the `PlayedWord(` call in `sendTurnUpdate`
- Modify: `server/internal/wsapi/convert.go` — `PlayedWord` signature, `Senses`
- Modify: `server/internal/wsapi/*_test.go` — fake dictionaries gain `Meanings`; new
assertions on `Played.Meanings` and `OpeningMeanings`, including after a resume
- Modify: `web/tests/game-wire.test.js` — the fixture decode asserts the new fields
## Implementation Steps
1. Edit the schema; `buf lint`; `buf generate`.
2. Interface and room changes; fix compile errors in tests by adding `Meanings` to fakes.
3. Add the assertions; `go test ./internal/wsapi -update` then `go test ./... -race`.
4. `cd web && npm test` — the wire test decodes the regenerated fixtures.
5. `make proto-check` — committed generated code matches the schema.
## Success Criteria
- [x] `buf lint` clean; `make proto-check` clean.
- [x] A bot game and a room game deliver `Played.Meanings` with `Pos` and `Gloss` for a word
the fake dictionary has senses for, and an empty list for one it does not.
- [x] A resumed session's `GameStarted.OpeningMeanings` and replayed `TurnUpdate.Played.Meanings`
are populated.
- [x] `proto/testdata/` fixtures regenerated and the JS suite decodes them.
## Risk Assessment
**Field numbers.** `PlayedWord` uses 1–6, `GameStarted` 1–8; 7 and 9 are free and nothing
is reserved in either. Signal: `buf breaking` complaint. Response: none expected; adding a
repeated field is wire-compatible, and there is one client.
**Meanings on `PlayerEliminated.suggestions`.** Out of scope by the plan's non-goals; a
reviewer may ask. Response: the suggestions are shown to somebody who just lost the turn,
a list of words, and a meaning under each would be a second UI. Not this plan.
@@ -1,133 +0,0 @@
---
phase: 5
title: "Phase 5: Meanings in the client"
status: completed
priority: P1
effort: "5h"
dependencies: [4]
---
# Phase 5: Meanings in the client
## Overview
Show a word's meanings under it in the chain, open for the newest word, closed for the rest,
with a click on any word toggling its own; keep the open/closed set in the store so the
rules are testable without a browser, and cover the behaviour end to end.
## Requirements
- Functional: `ChainEntry` gains `meanings: {pos: string, gloss: string}[]`, filled from
`played.meanings` and `value.openingMeanings`.
- Functional: the store keeps `expanded: Set<string>` of words whose meaning is open, and
`toggleMeaning(word)`. Any number of words may be open at once. Rules (validation
session 1: they apply to every word, with or without definitions):
- `gameStarted`: `expanded = {openingWord}`.
- `turnUpdate` with a word: remove the previous newest word (`chain[chain.length-1].word`
before the push), add the new word. A `turnUpdate` without a word (an elimination)
changes nothing.
- `toggleMeaning(word)`: flip membership.
- `reset()` clears the set. A resumed session gets `gameStarted` then one `turnUpdate` and
ends up with only the last word open, which is the same as a fresh one.
- Functional: `ChainHistory.svelte` renders every word as a `<button type="button">` with
`aria-expanded` and `aria-controls` pointing at its panel. The panel is shown when the word
is in `expanded`: an `<ol class="meanings">` with one `<li>` per sense rendered as
`(pos) gloss`, or `gloss` alone when `pos` is empty; when the word has no senses the panel
is a single `<p class="meanings none">` with `t.meaningNone`. Everything else in the row —
who played it, badges, points, the correction note — is unchanged.
- Functional: strings in `web/src/lib/i18n/vi.js`: `meaningShow` / `meaningHide` for the
button's `aria-label` (`Xem nghĩa của {word}` / `Ẩn nghĩa của {word}`) and `meaningNone`
(`Chưa có nghĩa`).
- Functional: the auto-scroll effect keeps working — the newest row is at the top and its
open list is what the scroll lands on.
- Functional: `history-export.js` is unchanged (non-goal).
- Non-functional: the meanings are rendered as text (`{sense}`), never with `{@html}`.
- Non-functional: the row stays legible on a phone: the list wraps under the word at full
row width (`flex-basis: 100%`), muted colour, ~0.85rem, numbered by the `<ol>`.
- Non-functional: `prefers-reduced-motion` respected if any open/close transition is added;
the simplest is none.
## Architecture
```
game.svelte.js
state.chain[i].meanings from the wire
state.expanded Set<string>, client-only
toggleMeaning(word)
ChainHistory.svelte
<li>
<button class="word" type="button" aria-expanded={open} aria-controls={id}
aria-label={fill(open ? t.meaningHide : t.meaningShow, { word: entry.word })}
onclick={() => game.toggleMeaning(entry.word)}>{entry.word}</button>
… by / meta / corrected as today …
{#if open}
{#if entry.meanings.length}
<ol class="meanings" {id}>
{#each entry.meanings as sense}<li>{sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}</li>{/each}
</ol>
{:else}
<p class="meanings none" {id}>{t.meaningNone}</p>
{/if}
{/if}
</li>
```
`id` is derived from the row's index in the chain, not from the word, so two rows never
share one (a word is never played twice, but the opening word could in principle be typed
as a later variant).
The e2e helper `chainWords` selects `ol li .word`; the class stays on the button so the
helper and every existing spec keep working, and the nested `<ol class="meanings">` has no
`.word` inside it.
## Related Code Files
- Modify: `web/src/lib/stores/game.svelte.js` — `ChainEntry` typedef, `expanded`,
`toggleMeaning`, the `gameStarted`/`turnUpdate`/`reset` branches
- Modify: `web/src/lib/components/ChainHistory.svelte` — markup and styles
- Modify: `web/src/lib/i18n/vi.js` — three strings
- Modify: `web/tests/game-store.test.js` — the expansion rules above, each as a case
- Modify: `web/tests/i18n.test.js` — only if it enumerates keys
- Modify: `web/e2e/helpers.js` — `openMeanings(page)` returning the words whose list is
visible; `web/e2e/bot-game.spec.js` — newest open, previous closed after a move, click
toggles; `web/e2e/pvp-game.spec.js` — the other browser sees the same word open
- Modify: `testdata/fixture-words.txt` — meanings for the words the specs play (phase 3)
## Implementation Steps
1. Store first, with tests: opening open; new word closes previous and opens itself; a word
with no meanings is opened the same way; elimination leaves the set alone; toggle flips
and two words can be open together; reset clears; the resume sequence ends with one open.
2. Component markup and styles; check both themes and a 360px viewport.
3. i18n strings; run the copy test.
4. e2e: fixture meanings, helper, three assertions. CI runs the browser; locally, run what
Playwright allows.
5. `npm run check && npm test`.
## Success Criteria
- [x] `npm run check && npm test` green; store tests cover all seven rules.
- [x] In a bot game the opening word's meaning is open; after the bot's first move only the
bot's word is open; clicking the opening word opens it again while the bot's stays
open, and clicking the bot's word closes it.
- [x] Senses render as `(danh từ) …`; a sense with an empty label renders the gloss alone; a
word without meanings opens to `Chưa có nghĩa`.
- [x] e2e specs assert the three behaviours and pass in CI.
- [x] Nothing in the transcript export changed.
## Risk Assessment
**The button steals focus from the word input.** The README says focus is left alone while
a player types, and a chain row that grabs focus on render would break that. Signal: typing
interrupted after a move. Response: never call `focus()` in the component; the button is
only focusable by the user's own click or Tab.
**Long meanings push the chain off screen on a phone.** Five senses of 200 characters is a
tall row. Signal: the newest row fills the viewport. Response: the cap is server-side and
already chosen; if it proves too tall, `max-height` with `overflow-y: auto` on the list is
a style change. Do not shorten text client-side — it would differ from what the server sent.
**A resumed session opens two words.** `gameStarted` opens the opening word; the replayed
`turnUpdate` must close it. Covered by the rule ordering and a store test using the resume
sequence.
@@ -1,122 +0,0 @@
---
phase: 6
title: "Phase 6: Measure and attribute"
status: completed
priority: P1
effort: "3h"
dependencies: [2, 3]
---
# Phase 6: Measure and attribute
## Overview
Record what the first dump-built database contains against the kaikki one, sample the
stripper's output, and rewrite every attribution surface so it names the Wikimedia dump,
drops kaikki.org and wiktextract, and stops claiming the database holds only word forms.
## Requirements
- Functional: `measurement.md` beside this plan, same shape as the kaikki plan's: the file
measured (URL, SHA-256, `source_pages`, `source_fetched_at`, size), the build log, the
graph metrics (words, syllables, openers, openers ≥2, dead ends) old vs new, bot-vs-bot at
60 games, the corpus diff (shared / gained / lost with samples), wall time of the build,
and the meanings numbers: `meaning_count`, `words_with_meaning` and its percentage, the
distribution of senses per word, the share of senses with an empty part-of-speech label
and the unmapped heading codes by frequency, the ten commonest dropped template names, and
a 50-sense random sample read by a person with each marked fine / terse / wrong.
- Functional: `data/ATTRIBUTION.md` — source table names the dump
(`viwiktionary-latest-pages-articles.xml.bz2`, monthly, unpinned), removes the wiktextract
row and the LREC citation, states the chain is now Wiktionary tiếng Việt contributors →
this project. Modifications list: item 1 becomes "section selection — the Vietnamese
section of each page, either markup dialect; redirects skipped"; a new item describes the
definition text: taken from `#` lines, markup stripped, templates other than the listed
ones removed, capped at five senses of 200 characters, each labelled with the Vietnamese
name of the part-of-speech heading it sat under, so the text is an *excerpt and
modification* of the entry, not the entry; item 8 ("dropped fields") says everything else
— examples, translations, pronunciations, etymologies, categories — is dropped, and the
sentence "contains only word forms, not meanings" is deleted. The
reproduce section names the new commands.
- Functional: `NOTICE` — affected artifacts name the dump file; the extraction lines go;
"identified by the SHA-256 recorded in the database's meta table" stays.
- Functional: README — licence section drops wiktextract/kaikki; Setup and the raw commands
already changed in phase 2; a sentence under the game description says the chain shows
each word's Wiktionary meaning.
- Functional: `docs/deployment.md` "Updating the dictionary" says dumps.wikimedia.org and
monthly.
- Functional: the in-game footer (`AttributionFooter.svelte`, `attributionSource`) already
says Wiktionary tiếng Việt and links vi.wiktionary.org; verify and leave.
- Functional: `verify` in the builder and the pin test are the only two places allowed to
encode numbers from this measurement (the coverage floor; the URL). Nothing else.
- Non-functional: the measurement is a record, not a gate — the plan's decision that nothing
regresses is checked against the numbers, and a regression is reported to the owner, not
silently accepted or silently fixed.
## Architecture
```sh
cp data/noitu.db /tmp/kaikki.db # before the first dump build
make fetch-dict && make dict
cd server && go test ./internal/bot/ -run RealCorpus -v -count=1 # per database
```
```sql
SELECT COUNT(*) FROM words; SELECT COUNT(*) FROM syllables;
SELECT COUNT(*) FROM (SELECT first FROM words GROUP BY first HAVING COUNT(*) >= 2);
SELECT COUNT(*) FROM syllables WHERE out_degree = 0;
SELECT COUNT(*) FROM meanings; SELECT COUNT(DISTINCT word) FROM meanings;
SELECT pos, COUNT(*) FROM meanings GROUP BY pos ORDER BY 2 DESC; -- '' is the unmapped share
SELECT n, COUNT(*) FROM (SELECT word, COUNT(*) n FROM meanings GROUP BY word) GROUP BY n;
SELECT word, gloss FROM meanings ORDER BY RANDOM() LIMIT 50;
ATTACH '/tmp/kaikki.db' AS old;
SELECT COUNT(*) FROM words w JOIN old.words o ON o.word = w.word; -- shared
SELECT word FROM words WHERE word NOT IN (SELECT word FROM old.words) ORDER BY RANDOM() LIMIT 25; -- gained
SELECT word FROM old.words WHERE word NOT IN (SELECT word FROM words) ORDER BY RANDOM() LIMIT 25; -- lost
```
## Related Code Files
- Create: `plans/260908-2056-wiktionary-dump-corpus-and-meanings/measurement.md`
- Modify: `data/ATTRIBUTION.md`, `NOTICE`, `README.md`, `docs/deployment.md`
- Verify only: `web/src/lib/components/AttributionFooter.svelte`, `web/src/lib/i18n/vi.js`
## Implementation Steps
1. Before the first real dump build, copy today's `data/noitu.db` aside.
2. Build; run the queries and the real-corpus bot tests on both databases; fill
`measurement.md`.
3. Read the 50 senses; mark each; if more than ~10 are wrong for the same reason, that is a
phase-1 stripper fix, made now, and the sample re-drawn.
4. Rewrite the four documents. Run `npx vitest run tests/dictionary-source.test.js` (README
and ATTRIBUTION quote the URL).
5. `grep -rniI "kaikki\|wiktextract\|jsonl\|only word forms" --exclude-dir=plans .` finds
nothing.
6. Plan status via `ak plan`; journal.
## Success Criteria
- [x] `measurement.md` complete with every table above; words ≥ 34,813 and no graph metric
below the kaikki database's, or the regression is written up and shown to the owner.
- [x] Meaning coverage recorded; the 50-sense sample recorded with verdicts.
- [x] `data/ATTRIBUTION.md`, `NOTICE`, README and `docs/deployment.md` describe the dump and
the definition text accurately; the pin test passes.
- [x] The grep in step 5 is empty.
## Risk Assessment
**The dump has fewer words than kaikki for some syllables.** Research measured the dump as a
near-superset (36,200 vs 34,813 through the same filter), but the two are different months
by the time this runs. Signal: `lost` is in the thousands. Response: list them, look for a
parser cause (a section boundary cut, a dialect miss) before accepting; a genuine wiki
deletion is accepted and recorded.
**The stripper sample is bad.** Signal: more than a fifth of 50 marked wrong. Response: fix
the stripper for the commonest cause in this plan; ship with the rest recorded; the
follow-up is a plan of its own, because it is template-by-template work against a moving
wiki.
**Attribution says less than the law wants.** CC BY-SA 4.0 asks for attribution, a licence
link, and an indication of modifications. The definition text makes the "modifications"
line load-bearing: the database now redistributes edited excerpts of the entries. The
ATTRIBUTION rewrite above is the compliance; a reviewer should read it against the licence
text once. Signal: none in code. Response: do the review.
@@ -1,283 +0,0 @@
---
title: "Wiktionary dump corpus and word meanings"
description: "Build data/noitu.db from the Wikimedia dump of Wiktionary tiếng Việt instead of kaikki.org's export, carry each word's definitions into the database, and show them in the chain: click to toggle, newest word open by default"
status: completed
priority: P1
effort: "~2.5d"
tags: [dictionary, data, proto, web, licensing, build]
created: 2026-09-08
blockedBy: []
blocks: []
---
# Wiktionary dump corpus and word meanings
## Overview
`data/noitu.db` is derived from kaikki.org's wiktextract export of Wiktionary tiếng Việt
(plan `260908-1653-kaikki-viwiktionary-corpus`, completed today): 34,813 words, word forms
only, fetched unpinned. Two things change here.
**The source becomes the Wikimedia dump itself.** `dumps.wikimedia.org/viwiktionary/` is the
file kaikki extracts from. Reading it directly removes the intermediary, its weekly refresh
and its `vi` extractor, which today loses ~1,400 real words the dump has (measured in
`plans/reports/research-260908-1529-viwiktionary-dump-measured.md`: **36,200** accepted
titles against kaikki's 34,813, with reduplicatives such as `rưng rức`, `nháo nhác`, `óc ách`
among the difference). The cost is a wikitext parser of our own — both markup dialects the
wiki currently mixes — instead of a JSON decoder.
**The database gains meanings.** Each word carries its Wiktionary definitions, stripped of
markup, capped at five senses of 200 characters. The client shows them under the word in the
chain: the newest word's meaning is open by default, a new word closes the previous one and
opens its own, and a click on any word toggles its meaning. This is the reason the source
matters: kaikki's glosses are pre-cleaned, but the dump's definitions cover the words kaikki
drops, and a stripper for `# ...` lines is a bounded job.
Owner decisions taken before this plan was written:
- **Track `latest/`, unpinned.** Same posture as today: `curl` whatever
`viwiktionary-latest-pages-articles.xml.bz2` currently is, record its SHA-256 in `meta`.
Dated directories exist and would pin honestly; the owner chose freshness. Wikimedia's dump
is monthly, so "fresh" now means monthly rather than weekly.
- **All senses, capped, each with its part of speech.** Every definition line of the
Vietnamese section travels, cut at 5 senses and 200 characters each, and each sense carries
the Vietnamese label of the heading it sat under (`danh từ`, `động từ`, `tính từ`, `danh từ
riêng` …), shown as a prefix: `(danh từ) Trẻ em học tập ở nhà trường.` A heading the
builder cannot map yields an empty label, never a dropped sense.
- **Every word is clickable.** A word with no definition still opens, to one line saying
`Chưa có nghĩa`, so the chain behaves the same for every word.
- **Nothing is dropped by label.** Carries over from the previous two plans: part of speech
and capitalization are tallied in the build log and never filter. `Danh từ riêng`
(`pr-noun`) labels are counted, not acted on.
- **One corpus input mode.** `--kaikki` is replaced by `--dump`, not kept beside it. `--words`
stays for the fixture and gains an optional meaning column so the e2e suite can see one.
## What changes, in numbers
| | today (kaikki) | after (dump) |
|---|---|---|
| words | 34,813 | **~36,200** (measured on the 2026-09-01 dump; phase 6 records the actual) |
| meanings | none | one to five per word; coverage measured in phase 6 (~5,700 pages have no `#` line and will have none) |
| source | 62 MB JSONL, weekly, unpinned | **~61 MB `.xml.bz2`, monthly, unpinned** |
| parser | `encoding/json`, 3 fields | `compress/bzip2` + `encoding/xml` + a wikitext section/definition scanner |
| `--min-words` floor | 30,000 | 30,000 (unchanged: 36,200 measured, and a dialect the parser misses is ~7,000 pages) |
| data licence | CC BY-SA 4.0 | CC BY-SA 4.0 (the database now carries definition *text*, so the share-alike obligation is more literal, not different) |
| wire | `PlayedWord` 6 fields | `message Sense {pos, gloss}`; `PlayedWord.meanings`, `GameStarted.opening_meanings` |
| `builder_version` | 4 | 5 |
## The source
```
URL https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2
Dated https://dumps.wikimedia.org/viwiktionary/20260901/viwiktionary-20260901-pages-articles.xml.bz2
63,513,513 bytes, MD5 6c2491e703e7d946f23a405996b4d172 (published), monthly on the 1st
Shape <mediawiki><page><title/><ns/><id/>[<redirect title=""/>]<revision><text>wikitext</text></revision></page>…
```
391,543 pages; 349,461 in namespace 0; **43,013 with a Vietnamese section**; 3,237 of those
are redirects (834 case-only, `mặt trời` → `Mặt Trời`) and are skipped, because the target
page is read on its own and lowercased by `accept()`.
Two markup dialects are live and the mix shifts monthly:
| | legacy (35,885 pages) | new (7,128 pages) |
|---|---|---|
| Vietnamese section opens | `{{-vie-}}` | `== {{langname\|vi}} ==` |
| section closes | next `{{-xxx-}}` whose code is *not* a known section code (`-eng-`, `-fra-`, `-tyz-` …) | next level-2 heading `== … ==` |
| POS heading | `{{-noun-}}`, `{{-verb-}}`, `{{-pr-noun-}}`, `{{-place-}}` … | `=== {{ĐM\|noun}} ===`, `{{vi-noun}}`, `{{vi-pr-noun}}` … |
| definition | a line starting `# ` (not `#:`, `#*`) under a POS heading | same |
Sample definitions as they sit in the dump and as they must come out:
```
{{-noun-}} … # [[chỗ|Chỗ]] [[râm]] [[mát]], do [[trời]] có [[mây]] hoặc do không bị [[nắng]] [[chiếu]].
→ pos "danh từ" gloss "Chỗ râm mát, do trời có mây hoặc do không bị nắng chiếu."
=== {{ĐM|pr-noun}} === … # {{place|vi|thủ đô|c/Việt Nam}}.
→ pos "danh từ riêng" gloss "thủ đô, Việt Nam."
# {{label|vi|thuộc lịch sử}} Một [[tỉnh]] cũ của [[Việt Nam]] vào nửa cuối thế kỷ XIX.
→ pos "danh từ riêng" gloss "(thuộc lịch sử) Một tỉnh cũ của Việt Nam vào nửa cuối thế kỷ XIX."
```
The client renders a sense as `(pos) gloss`, or `gloss` alone when the label is empty.
## Decisions taken
- **Parse in Go, inside `build-dictionary`.** `compress/bzip2` and `encoding/xml` are stdlib;
the Docker `dict` stage and the Makefile keep one command. Go's bzip2 is pure Go and slow
(tens of MB/s); a ~400 MB decompressed stream is well under a minute. If phase 1 measures
more than two minutes, the fallback is `bzip2 -dc` in the Makefile feeding a plain `.xml`
— a flag change, recorded as a risk below, not a redesign.
- **The stripper is small and lossy on purpose.** Links keep their display text; bold and
italic markers go; `<ref>` and comments go; `{{label|vi|x}}`/`{{lb|vi|x}}`/`{{gloss|x}}`
become `(x)`; `{{l|vi|x}}`, `{{vi-l|x}}`, `{{w|x}}` become `x`; `{{place|vi|a|b}}` keeps its
positional parameters after the language code with any `c/` prefix removed; **every other
template is dropped whole**, nesting handled by a depth counter. What survives is trimmed
and whitespace-collapsed; an empty result is not a sense. This will leave some definitions
terse or odd. Phase 6 samples 50 and records what the stripper got wrong; fixing template
by template is a follow-up, not this plan.
- **Meanings live in their own table** — `meanings(word, ord, pos, gloss)` — and are loaded
into memory by the store with the words. The label is a column, not baked into the gloss:
the 200-character cap applies to the definition, and the client decides how a label is
shown. Ten kilobytes of text per hundred words is a few
megabytes for the corpus; a per-move query would be a second code path for nothing.
- **The engine never sees a meaning.** `game.Dictionary` is unchanged. `wsapi.Dictionary`
gains `Meanings(word) []dictionary.Sense` and the room attaches them when it renders a `PlayedWord`
or a `GameStarted`, the same place `by_me` is decided. A reconnect already replays only the
opening and the last move, and both carry meanings, so nothing new is needed there.
- **Expansion state is client-only.** Which words are open is UI state, like the theme. The
store keeps a set of open words — any number may be open at once; `gameStarted` opens the
opening word, a `turnUpdate` with a word closes the previous newest and opens the new one,
a click toggles. These rules apply to every word, with or without a definition. The
transcript export is unchanged.
- **Meaning text is plain text end to end.** It is rendered as text, never as HTML; the
builder strips markup, the client escapes as Svelte does by default. Wiki text is
user-generated content and travels through the same sanitizer path as any other string
the server has not written itself: the builder caps length, drops control characters and
collapses whitespace so nothing the store loads can be shaped like a chat injection.
## Phases
| # | Phase | Status |
|---|-------|--------|
| 1 | [Read the dump](./phase-01-start.md) | Completed |
| 2 | [Fetch the dump](./phase-02-fetch-the-dump.md) | Completed |
| 3 | [Meanings in the database](./phase-03-meanings-in-the-database.md) | Completed |
| 4 | [Meanings on the wire](./phase-04-meanings-on-the-wire.md) | Completed |
| 5 | [Meanings in the client](./phase-05-meanings-in-the-client.md) | Completed |
| 6 | [Measure and attribute](./phase-06-measure-and-attribute.md) | Completed |
Phases 1 and 3 land together: a reader that extracts definitions and a schema that has
nowhere to put them is half a change. Phase 2 can land with them or just after. Phases 4
and 5 are one wire change and its consumer; the schema regenerates once. Phase 6 is
measurement and the attribution rewrite, and the attribution must land in the same commit
as the first dump-built database, because `data/ATTRIBUTION.md` today says the database
"contains only word forms, not meanings", which stops being true.
## Non-goals
- Dropping words by proper-noun label, capitalization or part of speech.
- Keeping kaikki as a fallback or second source.
- Meanings for the suggestions shown to an eliminated player, in the transcript export, or
anywhere outside the chain.
- A dictionary lookup UI. The meaning is shown for words that were played.
- Pinning the dump. Recorded as the open question it already was.
## Success criteria
- [x] `make fetch-dict && make dict` downloads `viwiktionary-latest-pages-articles.xml.bz2`
and builds `data/noitu.db` with more than 30,000 words and a meaning for the large
majority of them; the build log reports pages seen, pages with a Vietnamese section,
pages per dialect, redirects skipped, POS tallies and definition counts.
- [x] A truncated or non-bzip2 download, an XML stream that ends mid-page, and a page count
far below the norm each fail the build with a message naming the cause.
- [x] `meta` records `source_url`, `source_sha256`, `source_pages`, `source_fetched_at`,
`meaning_count` and `builder_version = 5`; `source_rows` is gone.
- [x] `Sense`, `PlayedWord.meanings` and `GameStarted.opening_meanings` are in the schema,
the generated Go and JS trees, and the binary fixtures under `proto/testdata/`.
- [x] In a bot game and a two-browser online game the newest word's meaning is open, the
previous word's closes when a new one arrives, and clicking any word toggles its own.
Senses show as `(danh từ) …`; a word with no definition opens to `Chưa có nghĩa`.
- [x] `data/ATTRIBUTION.md`, `NOTICE`, README, `docs/deployment.md`, the Dockerfile and the
builder's constants name the Wikimedia dump and no longer name kaikki.org or
wiktextract; the "only word forms" claim is replaced by an accurate description of the
definition text carried.
- [x] `go vet ./... && go test ./... -race`, `npm run check && npm test`, `npm run test:e2e`
(CI) and both Docker image variants are green; the CI leak guard rejects a `.bz2` or
`.xml` inside the image.
- [x] Phase 6's measurement is recorded beside this plan: word count, meaning coverage,
graph metrics and bot game lengths against the kaikki database, plus the 50-sense
stripper sample.
## Open questions
- **Unpinned `latest/`.** Wikimedia repoints `latest` once a month; a dump run can also fail
partway and leave `latest` on the previous month, which is harmless. Accepted by the owner,
same as for kaikki. `source_sha256` and `source_fetched_at` (the file's server-side
modification time, kept by `curl -R`) identify a build. Should reproducibility ever be
wanted, the dated URL is a one-variable change.
- **Definitions that are only a template.** `# {{place|vi|thủ đô|c/Việt Nam}}.` strips to
`thủ đô, Việt Nam.` — readable, not prose. Definitions that are *only* an unknown template
strip to nothing and the sense is dropped; if that leaves a word with no sense, the word
has no meaning shown. Phase 6 counts how many and lists the commonest template names so
the next round knows which to teach the stripper.
- **Headings the label map does not know.** `{{-xxx-}}` and `{{ĐM|xxx}}` codes outside the
map give an empty label; the sense is kept. Phase 6 lists unmapped codes by frequency so
the map can grow; a code seen on more than a few hundred definitions is added in phase 1.
- **Dialect drift.** The wiki is migrating legacy `{{-vie-}}` pages to `== {{langname|vi}} ==`.
Both are parsed; the build log prints the count per dialect so a month where one falls to
zero is visible. A third form appearing would show as a drop in `source_pages` and,
eventually, the floor.
## Validation Log
### Session 1 — 2026-09-08
Verification (Full tier, 6 phases; claims checked by reading the code while the plan was
written, then re-grepped): 14 checked, 13 verified, 1 failed, 0 unverified.
- FAILED → fixed: `web/e2e/fixture-dictionary.js` reads `testdata/fixture-words.txt` as one
word per line (`fixture-dictionary.js:15-19`); the tab-separated meaning column must be
stripped there. Phase 3 now lists it as a definite change, not a conditional one.
- Corrected: `PlayedWord(` has one caller, `room.go:903` inside `sendTurnUpdate`; the resume
path reuses it. Phase 4 no longer implies a second call site.
- Verified: `wsapi.Dictionary` at `room.go:268`, `sendGameStarted` at `room.go:744`,
resume replay at `room.go:1190-1195`, `Store.validate` word-count check at `store.go:213`,
`reset()` at `game.svelte.js:198`, `chainWords` selector `ol li .word` at
`e2e/helpers.js:184`, `TestDifficultyLadderRealCorpus` and `TestHardChooseLatencyRealCorpus`
in `bot/realcorpus_test.go`, `web/tests/game-wire.test.js` decodes `proto/testdata`,
`PlayedWord` fields 1–6 and `GameStarted` 1–8 with nothing reserved, `web/tests/i18n.test.js`
does not enumerate keys (phase 5's conditional line resolves to no change), Go 1.25.
Questions asked: 4.
| Question | Decision | Effect |
|---|---|---|
| Open state when another word is opened | Independent toggles; only the automatic close of the previous newest | Phase 5 rules unchanged; stated explicitly |
| Part-of-speech label on senses | **Prefix each sense with its POS** | `meanings.pos` column; `message Sense {pos, gloss}`; heading→label map in phase 1; client renders `(pos) gloss` |
| Meanings in the transcript download | No, chain only | Non-goal stands |
| A word with no definition | **Clickable, opens to `Chưa có nghĩa`** | Every word is a button; `meaningNone` string; auto-open applies to all words |
### Whole-Plan Consistency Sweep
Re-read `plan.md` and all six phase files after propagation. Searched for `no toggle`,
`no button`, `nothing to click`, `[]string`, `repeated string meanings`, `(word, ord, gloss)`,
`if it parses`, `wherever PlayedWord`. All occurrences reconciled to the decisions above. No
unresolved contradictions.
### Session 2 — 2026-09-08, implementation
All six phases implemented in one working tree; measurement in [`measurement.md`](./measurement.md).
| Claim in the plan | Found | Effect |
|---|---|---|
| `pron` → đại từ in the label map | `pron` is *pronunciation* on this wiki (36,533 legacy + 5,501 new headings); pronoun is `pronoun`/`per-pronoun` | map corrected; `pron` is a non-POS section code |
| New dialect headings are `{{ĐM|code}}` | also `{{section|code}}` (3,506) and shorthand codes `n`/`v` | both forms and both shorthands mapped |
| `{{-dfn-}}` is a heading | it is a "definitions" marker placed under a POS heading | transparent to the label |
| Bot's bzip2 may take > 2 min | 21–25 s for the whole build | no fallback |
| Empty-label senses under 10% | 11.9%, all from pages with no POS heading at all; every code seen more than once is mapped | accepted, recorded |
| Fixture bzip2 lives at `testdata/mini-dump.xml.bz2` | placed in the package's own `server/cmd/build-dictionary/testdata/` (Go convention; the repo-root `testdata/` is the Makefile's and e2e's) | path only |
| `expanded: Set<string>` in the store | a `string[]` with set semantics, because Svelte 5's `$state` proxies arrays and not Sets | same rules, same tests |
| Stripper teaches only `label`/`l`/`place` families | also `nhãn`/`context`/`term` (label spellings), `n-g`, and `see-entry`/`like-entry` → `Xem x`, the commonest definition-line templates | phase-1 risk response, recorded |
| First build lost 15 kaikki words | pages with `{{-vie-}}{{-pron-}}…{{-place-}}` on one line | scanner reads several headings per line; second build lost 0 |
Numbers: 36,200 words (34,813 shared with kaikki, 1,387 gained, 0 lost), 40,842 senses on
35,062 words (96.9%), 43,011 pages, no graph metric below the kaikki database's.
Verification: `go vet ./... && go test ./... -race` green; `npm run check && npm test` green
(183); `npx playwright test` 38/39 on each full run, the one failure being the four-player
seating spec ("a fifth is turned away"), which no changed file touches and which fails about
one run in three on this machine with local Chrome **on `HEAD` as well** (stashed tree,
`--repeat-each 4`: 3 passed, 1 failed) — a pre-existing flake, not a regression; both meanings
specs pass every run; Docker image variants
**not built locally** — the daemon was not running — and left to CI. `--min-pages` (default
20,000) was added to the builder so the page floor is a flag like `--min-words`, which the
tests need to lower.
Code review (`plans/reports/code-reviewer-260908-2210-…md`): no critical finding; one high
(a flaky e2e assertion on the opening's label), three medium (nested-list styling leak,
format characters surviving the stripper, opaque refusal of a v4 database) and eight low, all
fixed in the same tree and re-verified; see `measurement.md` "After code review".
<!-- slug: wiktionary-dump-corpus-and-meanings -->