diff --git a/plans/reports/code-reviewer-260921-1529-web-architecture-review.md b/plans/reports/code-reviewer-260921-1529-web-architecture-review.md
new file mode 100644
index 0000000..1e20120
--- /dev/null
+++ b/plans/reports/code-reviewer-260921-1529-web-architecture-review.md
@@ -0,0 +1,505 @@
+# Web frontend — whole-project architecture review
+
+Branch `dev` @ 5178a97 · 2026-09-21 · scope `/workspace/tiennm99/noitu/web` (src 4,392 LOC, tests 2,537, e2e 1,472)
+
+## Checks run (read-only)
+
+| Command | Result |
+|---|---|
+| `npm run lint` | 0 errors, **33 warnings** (32 × `jsdoc/reject-any-type`, 1 × `check-param-names` in `e2e/helpers.js:123`) |
+| `npm run check` | 380 files, **0 errors, 0 warnings** — see A4: this number is mostly meaningless today |
+| `npm test` | build OK, **221 passed / 12 files**, 3.8s |
+| Playwright | not run (no browser on this host, per workspace rules) |
+
+## Verdict
+
+Structurally sound and unusually well-reasoned — the "store is a projection" invariant holds everywhere I
+checked, the uncontrolled-input invariant is respected, and the comments explain *why* rather than *what*.
+Three things are genuinely wrong and one of them is a stuck-UI dead end reachable after any deploy. The
+bigger problem is not a defect: **`svelte-check`'s clean run is an illusion** — `initialState()` returns
+`any` (`stores/game.svelte.js:54`), so every `game.state.*` read in every component is unchecked. Fix that
+before any refactor, or the refactor lands blind.
+
+Do not slice the store into per-domain stores. Do extract the page's request state machine.
+
+## Top 10 ranked actions
+
+| # | Action | Kind | Size | Risk | Why now |
+|---|---|---|---|---|---|
+| 1 | Time-box the resume latch; a stale token gets **silence** from the server, not an error → `?code=` + stale token = permanently disabled join form | fix | S | L | Confirmed against `server/internal/wsapi/session.go:643` + `hub.go:127`. Reachable after every deploy |
+| 2 | `leave()` omits `forgetSession()` → next reload resumes into the room just left | fix | S | L | Confirmed; one-line asymmetry vs. the page-teardown path |
+| 3 | Type `GameState`; delete `@returns {any}` on `initialState()` | fix | M | M | Unblocks every other item. Expect real errors to surface |
+| 4 | Extract `stores/room-session.svelte.js` (join/resume/quick-match machine) from `online/+page.svelte` | refactor | M | M | Removes 5 of 7 `$effect`s; makes #1 unit-testable; `bot-session` is the precedent |
+| 5 | Type the wire from `game_pb.d.ts`; `switch (payload.case)` for oneof narrowing | refactor | M | L | Clears 18 src lint warnings; makes the oneof exhaustive at build time |
+| 6 | Fire-and-forget sends (`cancelQueue`, `leave`, lobby `report`) silently drop requests | fix | S | L | Best explanation for the `toBeEnabled` flake; user-visible dead buttons |
+| 7 | Split `game.svelte.js` → `game-shape.js` / `game-apply.js` / store; wrap `apply` in try/catch | refactor | M | L | A throw mid-`apply` leaves a half-applied snapshot on screen |
+| 8 | Component tests under jsdom via `mount()`; then cut Playwright 47 → ~12 | refactor | M | L | jsdom is already a devDependency; today 0 component tests exist |
+| 9 | `ArmedButton.svelte` (3 duplicated arm/disarm blocks) + announce the armed state | refactor | S | L | DRY + the only a11y gap that loses information |
+| 10 | `{#each entry.meanings as sense (sense.gloss)}` — duplicate gloss = Svelte duplicate-key throw | fix | S | L | Dictionary data is not guaranteed gloss-unique |
+
+**Leave alone:** per-domain store slices (§1), `vi.js` namespacing (§5), manual chunking / font strategy (§4),
+the uncontrolled-field design (§3), the `Set` + eslint-disable in `reset()` (dissolves under #3).
+
+---
+
+## 1. Structure
+
+### 1.1 `routes/online/+page.svelte` (757) — extract the request machine
+
+Seven `$effect`s, five of which are one state machine wearing a costume: `pending` (48), `resuming` (121),
+`needName` (125), `stalled` (126), `queuedForS` (56), and the latch-clearing effect at 137-143, the flush at
+216-219, the queue timer at 225-233, the stall timer at 239-247, the resume-failure handler at 253-268.
+
+**Proposal** (mirrors `stores/bot-session.svelte.js` exactly):
+
+- `lib/stores/room-session.svelte.js` (~130 LOC, no DOM, no runes beyond `$state`) — owns `pending`,
+ `resuming`, `needName`, `stalled`, `waitedS`; exposes `request(req)`, `flush(isOpen)`, `noteRoom()`,
+ `noteError(code)`, `noteResumeTimeout()`, `leave()`. Unit-testable in Vitest with a fake clock, exactly as
+ `ws/client.js` already is (630 lines of tests prove the pattern works).
+- `lib/components/JoinPanel.svelte` (~160) — the `{:else}` branch at 465-547: nickname, quick match, create,
+ join form, `needName` / `error` / `stalled` notices.
+- `lib/components/RoomLayout.svelte` (~90) — the two-column shell (408-464) plus `wide` (85), `chatFolded`
+ (103), `chatUnread` (104), `talkPane` (106) and the media-query effect (87-94).
+- Page drops to ~130: store wiring + the 11 one-line message senders (349-401).
+
+**Invariant impact: none.** `room-session` holds *client intent* (what the player asked for), never server
+state. `game` stays the sole projection. This is the boundary `bot-session.svelte.js:1-12` already argues for
+in prose.
+
+**Size M, risk M** — the risk is entirely in the teardown effect (185-212), which is load-bearing for seat
+release. Port it verbatim; cover it with the existing `pvp-game.spec.js:235` spec before and after.
+
+Secondary: the teardown at 185 is coupled to `inviteCode` (`$derived` on `page.url`, 128). Any future
+in-app URL mutation on `/online` — a `replaceState` to drop the used `?code=`, say — would fire a full
+leave-room-and-disconnect. Move teardown to `onDestroy` so it is not a reactive dependency of a query
+parameter.
+
+### 1.2 `stores/game.svelte.js` (646) — split the file, keep one state object
+
+**Do not make this four stores with a dispatcher.** Three reasons:
+
+1. The store's own comment at 300-302 states the failure mode a slice design invites: *"Merging fields
+ selectively is how a client ends up believing a mixture of two states the server was never in."* Four
+ reducers each handling part of a `RoomState` is precisely that, with the atomicity now spread across
+ module boundaries.
+2. Components read across the proposed domains. `ScoreBoard.svelte:15-17` reads `gamePlayers` +
+ `standings` + `roomPlayers` + `nickname`; `nameOf()` (593-601) falls back across game → room; `myScore`
+ (566-569) picks its table by `phase`.
+3. There is no performance motive. 61 store tests run in 36ms.
+
+**Proposal — mechanical file split, one `$state`:**
+
+- `stores/game-shape.js` (~210) — the `ChainEntry`/`Sense`/`PointPart`/`PlayerSlot`/`PlayerScore` typedefs,
+ the new `GameState` typedef (#3), `initialState()`, and the three wire decoders `toSenses`/`toParts`/
+ `toScore` (200-230). Pure, zero reactivity, the natural home for the generated-type imports.
+- `stores/game-apply.js` (~230) — `applyTo(state, msg)`: the switch at 286-518 as a pure function over a
+ plain object. Testable without the runes compiler.
+- `stores/game.svelte.js` (~200) — `$state`, `reset`, `leave`, the 12 derived accessors, the singleton.
+
+**Size M, risk L** (mechanical). Sequence it *after* #3 and #5 so the decoders land typed.
+
+While splitting, wrap the call site: `apply()` has no error boundary and is invoked from
+`ws/client.js:245-246` inside `ws.onmessage`. A throw anywhere in the switch aborts mid-mutation — e.g.
+`roomState` sets `queued`/`roomCode`/`canStart` (296-306) *before* mapping `players` (308), so a throw there
+leaves a room on screen with no seats. Protobuf-es v2 always materialises repeated fields, so this is
+plausible rather than confirmed, but the cost of `try { applyTo(...) } catch { /* report */ }` is one line.
+
+### 1.3 `components/GameBoard.svelte` (495)
+
+Two near-identical arm/disarm blocks: `arming`/`armTimer`/`armOrResign` (48-51, 99-109, 124) and
+`claimArming`/`claimArmTimer`/`armOrClaim` (52-54, 112-122, 125), plus their disarm-on-turn-loss effects
+(74-78, 84-88). `Lobby.svelte:55-81` has a third copy for kick.
+
+- `components/ArmedButton.svelte` (~45) — props `{ label, confirmLabel, disabled, onconfirm }`; owns the
+ timer, the disarm-on-disable effect, and (see §5) the announcement the armed state currently lacks. Three
+ call sites, ~70 LOC deleted. **S / L.**
+- `components/BoardHeader.svelte` (~55) — the `top` row at 129-154 (badge, mode label, rules link, chat
+ pill). Board falls to ~330.
+
+### 1.4 `components/Lobby.svelte` (467)
+
+Extract `components/SeatList.svelte` (~140): the `
` at 101-162 plus the kick arming, which
+becomes an `ArmedButton`. Lobby → ~300. **S / L.** Lowest priority of the four.
+
+### 1.5 Total
+
+~700 LOC moved, ~150 new, two new pure modules Vitest can reach. No abstraction without a domain anchor:
+every extracted unit is an existing repeated pattern (armed button, seat list) or an existing named concept
+(the room session, the wire decoders).
+
+---
+
+## 2. Typing without TypeScript
+
+18 `any` sites in `src/` (+ 12 in `tests/`+`e2e/`, 9 of which dissolve for free). `game_pb.d.ts` already
+carries everything needed: `ServerMessage.payload` is a proper discriminated union
+(`game_pb.d.ts:1185+`), and `moduleResolution: "bundler"` resolves `import('…/game_pb.js').ServerMessage`
+through the sibling `.d.ts`.
+
+### Site A — `ws/client.js:100, 269, 297` (the oneof)
+
+Before:
+
+```js
+ * @param {(msg: any) => void} options.onMessage
+…
+/** @param {any} msg */
+function intercept(msg) {
+ const payload = msg.payload;
+ if (payload.case === 'welcome') {
+ attempt = 0;
+ if (payload.value.resumeToken) storeToken(payload.value.resumeToken);
+```
+
+After:
+
+```js
+/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage */
+/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage */
+…
+ * @param {(msg: ServerMessage) => void} options.onMessage
+…
+/** @param {ServerMessage} msg */
+function intercept(msg) {
+ const payload = msg.payload; // NOT destructured — see below
+ if (payload.case === 'welcome') {
+ attempt = 0;
+ if (payload.value.resumeToken) storeToken(payload.value.resumeToken); // value: Welcome
+```
+
+`payload.case === 'pong'` then narrows `payload.value` to `Pong`, whose `clientTimeMs` is `bigint` — which
+makes the `Number(...)` at 287-288 a *checked* conversion rather than a hopeful one. `send` (297) takes
+`ClientMessage`; `messages.js` builders already return it, so no change there. The three timer `any`s
+(107, 126, 128) become `ReturnType`, which matches the injected `schedule: typeof
+setTimeout` exactly.
+
+### Site B — `stores/game.svelte.js:54` (the one that matters)
+
+`@returns {any}` on `initialState()` makes `const state = $state(initialState())` `any`, therefore
+`game.state` is `any`, therefore **every** `game.state.foo` in all 16 components is unchecked. A typo
+(`chainLenght`) compiles, ships, and renders `undefined`. This is why `svelte-check` reports 0 errors.
+
+Before:
+
+```js
+/** @returns {any} */
+function initialState() { return { phase: 'idle', chain: [], … }; }
+…
+// eslint-disable-next-line svelte/prefer-svelte-reactivity
+const kept = new Set(['nickname', 'roomCode', …]);
+function reset() {
+ const fresh = initialState();
+ for (const key of Object.keys(fresh)) {
+ if (kept.has(key)) continue;
+ state[key] = fresh[key]; // untyped index write
+ }
+}
+```
+
+After:
+
+```js
+/**
+ * @typedef {object} GameState
+ * @property {'idle'|'lobby'|'playing'|'over'} phase
+ * @property {ChainEntry[]} chain
+ * @property {string[]} expanded
+ * …one @property per field, ~30 lines, prose comments unchanged…
+ * @property {string|null} error
+ */
+
+/** @returns {GameState} */
+function initialState() { … }
+
+/** Fields a game ending does not change: they describe the room, not the game. */
+const KEPT = /** @type {const} */ ([
+ 'nickname', 'roomCode', 'roomPlayers', 'canStart',
+ 'maxPlayers', 'minPlayers', 'graceMs', 'chat', 'chatCount'
+]);
+
+/**
+ * @template {keyof GameState} K
+ * @param {GameState} into
+ * @param {GameState} from
+ * @param {readonly K[]} keys
+ */
+function carry(into, from, keys) {
+ for (const k of keys) into[k] = from[k]; // both sides are GameState[K]
+}
+
+function reset() {
+ const fresh = initialState();
+ carry(fresh, state, KEPT);
+ Object.assign(state, fresh);
+}
+
+function leave() { Object.assign(state, initialState()); }
+```
+
+The generic `carry` typechecks without a suppression, and the `Set` plus its `eslint-disable` disappear.
+
+### The one non-obvious step
+
+`apply()` currently destructures: `const { case: kind, value } = msg.payload;` (287). TypeScript **loses
+discriminated-union narrowing across a destructure**. The switch must become `switch (payload.case)` with
+`payload.value` read inside each arm. That is the only mechanical change to the 230-line switch; everything
+else is annotations.
+
+### Remaining sites
+
+- `game.svelte.js:200/204/209/213/218/308/471` → the wire types, renamed on import to avoid colliding with
+ the store's own same-named typedefs: `@typedef {import('…').PlayerScore} WirePlayerScore`. The `?? []`
+ guards at 204/213 are dead under protobuf-es v2 (repeated fields always materialise).
+- `ChainHistory.svelte:115` → `@param {import('$lib/stores/game.svelte.js').PointPart} p` (already exported).
+- `bot-session.svelte.js:68/74` → widen from `object|null` to `{ myScore: number }|null`, drop the cast.
+- `connection.svelte.js:36` → `ClientMessage`.
+- `tests/game-store.test.js:624/635/661` dissolve once `GameState` exists; `tests/ws-client.test.js` (6)
+ needs the same `ServerMessage` typedef; `e2e/helpers.js:123` → `@param {...Page} guests`.
+
+Net: 33 warnings → 0, and `svelte-check` starts earning its exit code. Budget for a handful of *real* errors
+surfacing in components on the first run — that is the point.
+
+---
+
+## 3. Runtime correctness sweep
+
+### Confirmed
+
+**C1 — Resume latch has no timeout; the server answers an unknown token with silence.**
+`online/+page.svelte:160-173` sets `resuming = true` and, when the URL carries a code, `pending = {kind:
+'join', code}`. `flush()` refuses to send anything while `resuming` (288). `resuming` is cleared only by
+arriving in a room (137-143) or by an error (253-268). Server side: `session.go:643`
+`if prior, ok := s.hub.resumable(token); ok && … { s.resumeFrom(prior) }` — and `hub.go:127-135` returns
+`ok == false` for an unknown token, with **no message sent**. So a stale token (server restarted, or the
+session was GC'd after grace) plus an invite link leaves the screen with an OPEN socket, `resuming` stuck
+true, `pending` stuck set → all three buttons disabled reading "Đang kết nối…" (509/513/538), and the
+`stalled` banner never fires because it requires `connection.status !== OPEN` (240). Permanent dead end;
+only a manual reload escapes, which reproduces it.
+*Fix:* a `RESUME_TIMEOUT_MS` timer armed alongside `resuming`, running the same path the error branch does.
+Belongs in `room-session` (#4). Secondary: ask the server to answer a non-resumable token explicitly — the
+`resumeFrom` comment at 651-656 already commits to "every failing branch has to say so", and this branch
+does not.
+
+**C2 — `leave()` does not forget the session.** 368-372 sends `LeaveRoom`, wipes the store, clears `pending`
+— but omits `forgetSession()`, which the page-teardown path at 203 does call. The token stays in
+sessionStorage, so the next load of `/online` in that tab takes the `hasStoredSession()` branch (160) and
+tries to resume into the room the player deliberately left. Best case the server refuses and C1's stuck
+state is one step away; worst case the seat is still live and they are put back.
+
+**C3 — Fire-and-forget sends.** `send()` returns a boolean everywhere else in this file and is checked at
+302 and in `Lobby.report()` (62-66). It is *ignored* at `cancelQueue` (328) and `leave` (369). Cancelling a
+quick match while the socket is reconnecting therefore clears `pending` locally while `game.state.queued`
+stays true — the waiting panel (490-503) stays up with its elapsed clock running and its Cancel button
+doing nothing on every subsequent press. Same class as the flake below.
+
+**C4 — Duplicate-key throw on meanings.** `ChainHistory.svelte:124`
+`{#each entry.meanings as sense (sense.gloss)}`. Two senses with different `pos` and identical `gloss` are
+not excluded by the wire contract; Svelte throws `each_key_duplicate`. Key by index — the list is neither
+reordered nor filtered. Same fragility, lower probability, at `ChainHistory.svelte:68`
+`{#each entries as entry, index (entry.word)}` (safe only because the server refuses repeats).
+
+**C5 — The lobby chat never reopens after the first game.** `chatFolded` starts `false` (103) and is set
+`true` by the effect at 108-110 when `phase === 'playing'`. Nothing ever sets it back. After a game ends the
+phase goes `over` → (next game) `playing`; it never returns to `lobby` while the player stays in the room
+(`game.svelte.js:322` only promotes `idle` → `lobby`). So on narrow screens the between-games lobby has a
+folded chat forever after the first game — the regression that 5178a97's fix does not cover. The pill in the
+board header keeps it reachable, so this is UX, not a trap. Fix: fold on the `lobby → playing` transition
+only, or unfold on `over`.
+
+**C6 — Seeding writes into a field the connection has disabled.** `WordInput.svelte:103-128` depends on
+`myTurn`/`phase`/`turnSeq`/`currentSyllable`/`rejection` but **not** on `connection.status`, while `enabled`
+(22-24) does. During a reconnect on the player's own turn the effect still calls `field.focus()` and writes
+`field.value = "${syllable} "`, after `lockedValue` was captured pre-seed at 70-73. The first composition
+event then calls `undoInput` (92-95) and yanks the seed back out. Cosmetic, but it makes the seed
+non-deterministic exactly when the player is anxious. Add `enabled` to the guard at 104.
+
+**C7 — `console.warn` ships to production.** `game.svelte.js:514`. Intentional per the comment, and I agree
+with the intent — but it prints internal oneof case names to the console of every player on a version skew.
+Route it through a one-shot dev-only guard or leave it; low.
+
+### Plausible
+
+**P1 — the `toBeEnabled` flake in `e2e/helpers.js:130.** `readyAndStart` clicks the guest's ready button and
+immediately asserts the owner's Start is enabled (10s `expect` timeout). `Lobby.svelte` line ~213 gates it on
+`!s.canStart || offline`, both server-owned, so the assertion is correct — but nothing between the click and
+the assertion proves the `SetReady` *left the client*. `onready` → `report(onready(...))` (224) → `send()`,
+which returns `false` if the socket is not `readyState === 1` (`client.js:299`) and **nothing retries**:
+`unsent` (60) draws a banner and the request is gone. Meanwhile the button itself is only disabled on
+`offline`, which is `connection.status`, and status reaches `OPEN` inside `ws.onopen` — a window exists where
+Playwright sees an enabled button over a socket in an ambiguous state.
+*Two fixes, both worth it:* (a) give lobby actions the same held-request latch `pending` already gives
+join/create (lands free with #4); (b) in the helper, assert the guest's own state before starting —
+`await expect(guest.getByTestId('my-ready')).toHaveText('Sẵn sàng')` — which converts a mystery timeout into
+a precise failure on the right page.
+
+**P2 — route-swap socket race.** `/online` teardown calls `disconnect()` (209) which nulls the module
+singleton; `/play` mount calls `connect()` (69). If SvelteKit ever creates the new page component before
+destroying the old one, `connect()` runs first and `disconnect()` then kills the socket the new screen owns,
+leaving `/play` with `pending` set and no transport and no retry (`session.flush` is only driven by a status
+*change*, `play/+page.svelte:49-52`). I could not prove the ordering without a browser. A `connect()` that is
+idempotent-by-generation, or an owner token on the singleton, removes the question.
+
+**P3 — resume-failure handler swallows unrelated errors.** `online/+page.svelte:253-268` treats *any*
+`game.state.error` arriving while `resuming` as a failed resume: it clears the error silently and spends the
+held code. A `server_full` or `too_fast` landing in that window is erased with no trace. Narrow it to the
+codes the resume path can actually produce (`game_already_over`).
+
+### Verified correct (do not "fix")
+
+- **Countdown vs. server clock** — `countdown.js`, the midpoint offset estimate (`client.js:293`), and the
+ rAF loop gated on `running` (`CountdownRing.svelte:30-37`), `SETTLE_MS` erring early included.
+- **Chat unread accounting** — the two effects at `ChatPanel.svelte:64-66` and 83-86 are correct under fold,
+ resync (`count < seenAt`), the `CHAT_WINDOW` cap (counted against `chatCount`, not `chat.length`), and the
+ single-mount-across-phases arrangement the page comment at 417-422 depends on.
+- **Storage failures** — `settings.svelte.js:389-421` and `client.js:68-90` guard the property access itself,
+ not just the call; `readBestScores` (428-445) validates shape and value; `app.html`'s pre-paint theme
+ script has its own `try`.
+- **Uncontrolled-input invariant** — `beforeinput` guard + `input` undo + composition flags
+ (`WordInput.svelte:83-95, 205-208`) and the three direct-write exceptions (seed 124, suggestion 164, clear
+ 146) are each correct at their call site. Only C6 breaks the pattern.
+- **`untrack` usage** — load-bearing at `+page.svelte` 139/150/218/255 and `play/+page.svelte` 37/51/58; each
+ prevents a self-retriggering effect, none hides a dependency that should be tracked.
+- **`PlayerStatus.svelte:36-48`** grace bookkeeping handles the same-length-different-ids case correctly.
+
+---
+
+## 4. Performance and bundle
+
+Client build (`vite build`): largest chunk 83.13 kB / 25.98 kB gz (Svelte runtime + `@bufbuild/protobuf`),
+then 33.37 / 26.18 / 21.91 kB; `/online` route node 20.61 kB; CSS 13.61 kB (GameOverPanel) + 9.70 kB
+(online route) + 3.98 kB (layout). Whole build comfortably under the 400 kB budget.
+
+**`tests/bundle.test.js` guards, well:** dictionary words absent *after unicode-escape decoding*; no
+`.db/.sqlite/.csv/.tsv`; total < 400 kB; build newer than src, so the assertions cannot go vacuous — that
+last one is the good idea here, most bundle tests are silently stale. **Gaps:** no per-chunk ceiling (a
+single 300 kB chunk passes), no gzip assertion, no sourcemap check. A largest-chunk budget is ~6 lines and
+is the one that would catch an accidental dependency.
+
+**Chunking / fonts / CSS: nothing to do.** No web fonts (`app.css:45` — correct where system fonts already
+carry the diacritics). No `manualChunks`, and none is warranted: every screen needs the socket and the proto
+on first interaction. `preload-data="hover"` over five routes is cheap. `ssr = false` + `prerender = false`
+means the SSR output under `.svelte-kit/output/server/` is built but never copied into `build/`.
+
+**Svelte 5 idioms: current throughout** — runes, snippets instead of slots, `page` from `$app/state` not the
+deprecated `$app/stores`. No `export let`, no `$:`, no `createEventDispatcher`. Two nits: the bare
+`messages.length;` dependency read at `ChatPanel.svelte:92` is obscure (assign it to a `const`), and
+`$effect(() => () => clearTimeout(t))` (`GameBoard.svelte:124-125`, `Lobby.svelte:81`) is a dependency-free
+effect used purely for teardown — `onDestroy` states that plainly and cannot later acquire a dependency.
+
+---
+
+## 5. Accessibility and i18n
+
+**Good already, unusually so:** skip link; `sr-only` `` on the room screen (413); zero-specificity
+`:focus-visible` ring via `:where()` (`app.css:132-138`); 44px `.icon-button`; `prefers-reduced-motion`
+honoured in CSS *and* script (`motion.js` — necessary, since an explicit `behavior` beats the CSS rule);
+turn indicator as `role="status" aria-live="polite" aria-atomic` (`GameBoard.svelte:203-210`); `role="timer"`
+plus a separate two-mark spoken region rather than 60 announcements/second (`CountdownRing.svelte:48-51,
+77-79`); deliberate *non*-duplication of the connection announcement (165-171); focus moved to the result
+panel rather than to its button (`GameOverPanel.svelte:25-34`).
+
+**Gaps, ranked:**
+
+1. **Armed buttons announce nothing.** Resign/claim/kick change their own label on the first press
+ (`GameBoard.svelte:239`, `Lobby.svelte:146`). A screen reader announces a control's name on focus, not on
+ in-place mutation — so a non-sighted player presses once, hears nothing, and either presses again blind or
+ walks away. Fold `aria-pressed` (or a discreet live region) into `ArmedButton` (#9).
+2. **Chat log live region.** `ChatPanel.svelte` (the ``)
+ announces the reader's *own* messages back to them, and `aria-relevant` is inconsistently implemented.
+ Prefer a dedicated `sr-only` `role="log"` carrying only the newest line where `fromMe` is false — the
+ pattern `CountdownRing` already uses.
+3. **Rejection alert doubles as a description.** `WordInput.svelte:200-202` points `aria-describedby` at the
+ same element that carries `role="alert"` (220), so the text is announced twice, and the alert contains two
+ focusable buttons. Split: an `sr-only` alert for the announcement, a plain `` for
+ the description and the buttons.
+4. **Focus after suggestion-fill is right, state is not.** `useSuggestion()` (161-167) focuses the field and
+ places the caret correctly, but leaves `game.state.rejection` set — so `aria-invalid` stays true and the
+ banner still offers the suggestion the player just took.
+5. **`ScoreBoard` turn row has no `aria-current`.** `class:active` (24) is the turn indicator for sighted
+ users only; the row is otherwise indistinguishable.
+6. **Colour contrast is fine.** Spot-checked the risky pairs: `--warn #8a5a08` on `--surface-alt #e9f0ea`
+ ≈ 5.1:1; `--text-muted #5b6a61` on `--bg #f4f7f4` ≈ 5.2:1; `--player-1 #1d5c8f` on white ≈ 7.1:1;
+ `--player-3 #8a4c12` on white ≈ 6.7:1; dark-theme `--warn #e9b949` on `#1f2a23` ≈ 8.1:1. All ≥ 4.5:1 at
+ the sizes used. `--border-strong` exists precisely because `--border` does not clear 3:1, and says so.
+
+**`vi.js` (359 lines) — leave it flat.** Namespacing buys nothing here: one locale, no runtime loader, no
+key collisions, and `t.foo` is already checked by `svelte-check` (it is a typed object literal, so `t.subimt`
+is a compile error today — this is one of the few places typing currently works). The existing
+comment-grouped sections are the right amount of structure. The real gap is that `tests/i18n.test.js` covers
+the *enum-derived* maps exhaustively (reject reasons, end reasons, point kinds, difficulties, error codes)
+but nothing asserts that `fill()` placeholders in the flat `t` keys match their call sites — a `{name}` typo
+in `playerTurn` renders literally. A ~15-line test extracting `{…}` tokens from each `t` value and checking
+them against `fill()` call sites would close it.
+
+---
+
+## 6. Tests
+
+**Vitest, by module.** Covered: `countdown` (10), `room-code` (14), `history-export` (8), `i18n` (14),
+`settings` store (16), `ws/client` (32 — genuinely good: injected clock, socket factory, scheduler),
+`game` store (61, fed real decoded `ServerMessage`s — the right call), `bot-session` (12), wire round-trip
+(43), error codes (3), dictionary source (4), bundle (4).
+
+**Uncovered: every `.svelte` file.** There are zero component tests and no component-test harness. Nothing
+in `tests/` imports a component. So all of this is browser-only today:
+
+| Behaviour | Where it lives |
+|---|---|
+| Chat unread across fold / phase / resync | `ChatPanel.svelte:58-96` — *this is what broke CI today* |
+| Word field seed, guard, undo, suggestion-fill, submit-clear | `WordInput.svelte:70-167` |
+| Arm/disarm and the disabled matrix | `GameBoard.svelte:74-125`, `Lobby.svelte:55-81` |
+| Ring states (mine / stalled / urgent / idle) | `CountdownRing.svelte:15-51` |
+| Grace countdown bookkeeping | `PlayerStatus.svelte:36-54` |
+
+**Recommendation: mount components under jsdom.** `jsdom` is *already* a devDependency and two suites
+already opt in per-file; Svelte 5 components compiled by the vite plugin mount directly with no extra
+library:
+
+```js
+// @vitest-environment jsdom
+import { mount, unmount, flushSync } from 'svelte';
+import ChatPanel from '../src/lib/components/ChatPanel.svelte';
+```
+
+No new dependency, no browser, runs on this ARM64 host. Start with `ChatPanel` (unread) and `WordInput`
+(seed/guard) — between them they cover the two most-regressed behaviours in the tree.
+
+**Playwright: 47 specs across 3 files, `workers: 1`, `fullyParallel: false`, 60s timeout.**
+
+*Delete — the Go suite already proves the rule, and the Vietnamese string is proven by `i18n.test.js`:*
+`bot-game.spec.js:114` (word not in dictionary), `:127` (too short), `:137` (does not link),
+`pvp-game.spec.js:588` (unknown room code), `:608` (latecomer refused), `:624` (fifth player turned away),
+`:394` (room outlives its owner).
+
+*Replace with a unit or component test:* `bot-game.spec.js:242` (personal best — `settings-store.test.js`
+owns the logic), `:264` (attribution footer — static markup), `:40` (syllable pre-seeded — a `WordInput`
+component test), `pvp-game.spec.js:460` and `:528` (chat unread / send gating — a `ChatPanel` component
+test). `bot-game.spec.js:272` (deep link served by the binary, not a 404) is a *server* concern and belongs
+in the Go suite.
+
+*Keep — genuinely browser-only:* all of `reconnect.spec.js` (socket cut, resume, forfeit, dead-connection
+typing — multi-context and transport-level); `pvp-game.spec.js:100` (turns alternate), `:140`/`:164` (invite
+link, nameless guest), `:193` (uncontrolled field refuses text out of turn — IME behaviour no jsdom test can
+prove), `:497` (a turn does not steal the chat field), `:515` (rendered as text, never markup — the XSS
+assertion), `:674` (spectator after knockout); `bot-game.spec.js:16` (one end-to-end game), `:195` (download
+plumbing), `:217` (rematch starts exactly one game).
+
+47 → ~14, multi-player and reconnect coverage untouched. Sequence *after* the component tests land.
+
+---
+
+## Unresolved questions
+
+1. **P2 (route-swap socket race):** does SvelteKit destroy the outgoing page component before creating the
+ incoming one? Unverifiable here without a browser. If it does not, `/online → /play` can leave `/play`
+ transport-less.
+2. **C1's server half:** should `handleHello` answer a non-resumable token explicitly? That is a protocol
+ change and a server decision — the client-side timeout (#1) is sufficient and should land regardless, but
+ the explicit answer is the better contract and `resumeFrom`'s own comment already argues for it.
+3. Does the dictionary builder guarantee gloss-unique senses per word (C4)? If it does, the duplicate-key
+ risk is theoretical — but the key should still not depend on a guarantee nothing in this repo states.
+4. Is the `chatFolded` behaviour in C5 intended (fold once, stay folded) or an unnoticed consequence of
+ 5178a97? This is a product call, not a defect call.
+5. Playwright wall-clock today is unmeasured here (no browser). The 47 → 14 recommendation assumes the suite
+ is a meaningful part of CI time; if it runs in under three minutes, prioritise #8's component-test half
+ and defer the deletions.
diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte
index 45354cd..eb12f50 100644
--- a/web/src/lib/components/ChainHistory.svelte
+++ b/web/src/lib/components/ChainHistory.svelte
@@ -112,7 +112,9 @@
travel with the total. -->
{entry.parts
- .map((/** @type {any} */ p) => `+${p.value} ${pointKindLabels[p.kind] ?? ''}`)
+ .map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) =>
+ `+${p.value} ${pointKindLabels[p.kind] ?? ''}`
+ )
.join(' · ')}
{/if}
@@ -121,7 +123,11 @@
stripped the wiki markup and nothing here re-interprets it. -->
{#if entry.meanings.length}
- {#each entry.meanings as sense (sense.gloss)}
+
+ {#each entry.meanings as sense, senseIndex (senseIndex)}
- {sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}
{/each}
diff --git a/web/src/lib/components/WordInput.svelte b/web/src/lib/components/WordInput.svelte
index 7dd590b..f2a3938 100644
--- a/web/src/lib/components/WordInput.svelte
+++ b/web/src/lib/components/WordInput.svelte
@@ -99,9 +99,13 @@
// start with — that part of the answer is already decided, and typing it
// again is the one keystroke sequence every single turn shares.
//
- // Reading myTurn is what subscribes the effect.
+ // Gated on `enabled` rather than just the turn and the phase: seeding
+ // during a reconnect wrote into a field the player could not submit from,
+ // and the first composition event then undid the seed anyway (undoInput
+ // yanks back anything typed while offline), making it non-deterministic
+ // exactly when the player is anxious about a running clock.
$effect(() => {
- if (!(game.state.myTurn && game.state.phase === 'playing')) return;
+ if (!enabled) return;
const turn = game.state.turnSeq;
const syllable = game.state.currentSyllable;
const rejection = game.state.rejection;
diff --git a/web/src/lib/stores/game-apply.js b/web/src/lib/stores/game-apply.js
new file mode 100644
index 0000000..a954241
--- /dev/null
+++ b/web/src/lib/stores/game-apply.js
@@ -0,0 +1,268 @@
+import { rejectMessage, errorMessage, fill, t } from '$lib/i18n/vi.js';
+import { toParts, toSenses, toScore, toSlot } from './game-shape.js';
+
+/**
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage
+ * @typedef {import('./game-shape.js').GameState} GameState
+ */
+
+// Ordinal handed to each chat line as it arrives, for list keys. Never reset:
+// a replayed history must not reuse numbers a line still on screen holds.
+let chatOrdinal = 0;
+
+/**
+ * How many messages the panel holds. The same window the server keeps, so the
+ * two can never disagree about what the conversation is.
+ */
+export const CHAT_WINDOW = 20;
+
+/**
+ * Applies one ServerMessage to a GameState in place.
+ *
+ * A pure function over a plain object — `state` need not be a Svelte proxy —
+ * which is what lets this run under plain Vitest and, wrapped by the store,
+ * under `$state` in the browser. `reset` and `leave` are handed in rather
+ * than imported, so this module never has to know how the store returns to
+ * its pre-game shape.
+ * @param {GameState} state
+ * @param {ServerMessage} msg
+ * @param {{ reset: () => void, leave: () => void }} lifecycle
+ */
+export function applyTo(state, msg, { reset, leave }) {
+ const payload = msg.payload;
+
+ switch (payload.case) {
+ case 'welcome':
+ // The server sanitizes the requested name, so what it returns
+ // is the only name safe to display — never the raw input.
+ state.nickname = payload.value.acceptedNickname;
+ break;
+
+ case 'roomState': {
+ const value = payload.value;
+ // A room existing is proof the wait is over, whether or not a
+ // quickMatchStatus already said so.
+ state.queued = false;
+ // One snapshot, applied wholesale. Merging fields selectively
+ // is how a client ends up believing a mixture of two states
+ // the server was never in.
+ state.roomCode = value.roomCode;
+ state.canStart = value.canStart;
+ state.maxPlayers = value.maxPlayers;
+ state.minPlayers = value.minPlayers;
+ state.graceMs = value.graceMs;
+ state.roomPlayers = value.players.map(toSlot);
+ // The lobby is where a room sits when no game is on. `over`
+ // keeps its result panel, which the lobby appears beneath.
+ if (state.phase === 'idle') state.phase = 'lobby';
+ break;
+ }
+
+ case 'gameStarted': {
+ const value = payload.value;
+ // reset() clears the readiness that led here, along with the
+ // last game's board and its knockouts.
+ reset();
+ state.phase = 'playing';
+ state.chain = [
+ {
+ word: value.openingWord,
+ typed: '',
+ byMe: false,
+ playerId: '',
+ points: 0,
+ syllables: 0,
+ opening: true,
+ meanings: toSenses(value.openingMeanings),
+ parts: []
+ }
+ ];
+ // The opening word is the newest word there is.
+ state.expanded = [value.openingWord];
+ state.currentSyllable = value.currentSyllable;
+ state.myTurn = value.myTurn;
+ state.deadlineMs = Number(value.deadlineUnixMs);
+ state.turnSeq = value.turnSeq;
+ state.turnLimitMs = value.turnLimitMs;
+ state.chainLength = 1;
+ state.gamePlayers = value.players.map(toScore);
+ state.turnPlayerId = value.turnPlayerId;
+ break;
+ }
+
+ case 'turnUpdate': {
+ const value = payload.value;
+ const played = value.played;
+ // A turn update with no word is an elimination moving the turn
+ // on: the syllable and the chain survive the player who could
+ // not answer them, so there is nothing to append.
+ if (played) {
+ // The newest word takes over the open panel from the one
+ // before it. Words the player opened by hand stay open.
+ const previous = state.chain[state.chain.length - 1]?.word;
+ state.chain.push({
+ word: played.word,
+ typed: played.typed,
+ byMe: played.byMe,
+ playerId: played.playerId,
+ points: played.points,
+ syllables: played.syllables,
+ opening: false,
+ meanings: toSenses(played.meanings),
+ parts: toParts(played.parts)
+ });
+ state.expanded = state.expanded.filter((w) => w !== previous);
+ if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
+ }
+ state.currentSyllable = value.currentSyllable;
+ state.myTurn = value.myTurn;
+ state.deadlineMs = Number(value.deadlineUnixMs);
+ state.turnSeq = value.turnSeq;
+ state.chainLength = value.chainLength;
+ state.gamePlayers = value.players.map(toScore);
+ state.turnPlayerId = value.turnPlayerId;
+ // An accepted move answers the previous rejection — and only
+ // an accepted move does. A wordless update is somebody being
+ // eliminated, which says nothing about the word this player
+ // was just refused, and wiping the reason off their screen is
+ // one player's exit costing another the only explanation they
+ // had.
+ if (played) {
+ state.rejection = null;
+ state.reportConfirmation = null;
+ }
+ // A false dead-end claim is about the position this update
+ // just moved past, however the turn moved.
+ state.claimError = null;
+ break;
+ }
+
+ case 'moveRejected':
+ state.rejection = {
+ word: payload.value.word,
+ message: rejectMessage(payload.value.reason, state.currentSyllable),
+ reason: payload.value.reason,
+ suggestion: payload.value.suggestion ?? ''
+ };
+ // A new rejection has nothing reported against it yet.
+ state.reportConfirmation = null;
+ break;
+
+ case 'wordReported':
+ state.reportConfirmation = fill(t.wordReported, { word: payload.value.word });
+ break;
+
+ case 'playerEliminated': {
+ const value = payload.value;
+ state.lastOut = {
+ playerId: value.playerId,
+ name: value.name,
+ isMe: value.isMe,
+ reason: value.reason
+ };
+ // Only the player who went out is sent suggestions, and only
+ // they have a use for them: they describe the position that
+ // beat them, which is nobody else's position.
+ if (value.isMe) {
+ state.myTurn = false;
+ state.elimination = {
+ playerId: value.playerId,
+ name: value.name,
+ reason: value.reason,
+ suggestions: value.suggestions ?? []
+ };
+ }
+ break;
+ }
+
+ case 'gameOver': {
+ const value = payload.value;
+ state.phase = 'over';
+ state.myTurn = false;
+ state.standings = value.standings.map(toScore);
+ const mine = state.standings.find((p) => p.isMe);
+ state.result = {
+ iWon: value.iWon,
+ reason: value.reason,
+ myScore: mine?.score ?? 0,
+ chainLength: value.chainLength
+ };
+ break;
+ }
+
+ case 'chatMessage':
+ state.chat.push({
+ n: ++chatOrdinal,
+ fromMe: payload.value.fromMe,
+ playerId: payload.value.playerId,
+ author: payload.value.author,
+ text: payload.value.text,
+ // int64 on the wire, which the runtime hands over as a
+ // bigint. Nothing downstream expects one.
+ atMs: Number(payload.value.sentUnixMs)
+ });
+ state.chatCount++;
+ // Trimmed to the server's window, so a long conversation and a
+ // replayed one are the same list.
+ if (state.chat.length > CHAT_WINDOW) {
+ state.chat = state.chat.slice(-CHAT_WINDOW);
+ }
+ break;
+
+ case 'chatHistory':
+ // A snapshot replaces; it never merges. It is also what a
+ // client arriving in a new room is given, so a conversation
+ // cannot outlive the room it was had in.
+ state.chat = payload.value.messages.map((m) => ({
+ n: ++chatOrdinal,
+ fromMe: m.fromMe,
+ playerId: m.playerId,
+ author: m.author,
+ text: m.text,
+ atMs: Number(m.sentUnixMs)
+ }));
+ state.chatCount = state.chat.length;
+ break;
+
+ case 'error': {
+ const value = payload.value;
+ // Two of them also end this player's membership of the room, so
+ // the model has to stop describing one. Set after, because
+ // leaving clears everything including the message.
+ if (value.code === 'kicked' || value.code === 'room_idle_closed') leave();
+ // A false dead-end claim is answered next to the input, not in
+ // the top banner: it is about the move just attempted, not a
+ // room-wide condition every screen has to show.
+ if (value.code === 'not_a_dead_end') {
+ state.claimError = errorMessage(value.code);
+ break;
+ }
+ // A match the server could not open leaves nobody queued, and
+ // the only frame that says so is this refusal.
+ if (
+ value.code === 'server_full' ||
+ value.code === 'room_start_failed' ||
+ value.code === 'server_restarting'
+ ) {
+ state.queued = false;
+ }
+ state.error = errorMessage(value.code);
+ break;
+ }
+
+ case 'quickMatchStatus':
+ state.queued = payload.value.queued;
+ break;
+
+ case 'pong':
+ // Handled by the transport, which owns the clock offset.
+ break;
+
+ default:
+ // A message this build does not know. Silence would make the
+ // next contract addition look like a network problem, so say
+ // so once rather than dropping it invisibly.
+ console.warn('unhandled server message', payload.case);
+ break;
+ }
+}
diff --git a/web/src/lib/stores/game-shape.js b/web/src/lib/stores/game-shape.js
new file mode 100644
index 0000000..d326883
--- /dev/null
+++ b/web/src/lib/stores/game-shape.js
@@ -0,0 +1,227 @@
+/**
+ * The game model's shape: what a `GameState` looks like, the value it starts
+ * at, and the pure decoders that turn a wire message's repeated fields into
+ * it. Nothing here is reactive — `$state` is applied once, by the store that
+ * owns this shape — which is what lets `initialState()` and the decoders run
+ * under plain Vitest with no Svelte runtime involved.
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').Sense} WireSense
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').PointPart} WirePointPart
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').PlayerSlot} WirePlayerSlot
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').PlayerScore} WirePlayerScore
+ */
+
+/**
+ * The game model is a projection of what the server sent. The client never
+ * decides whether a word is valid, whose turn it is, or who won — it renders
+ * the last message it received. That is what makes the bot and the online
+ * modes the same screen.
+ * @typedef {object} ChainEntry
+ * @property {string} word - the canonical spelling
+ * @property {string} typed - what the player actually typed, when it differed
+ * @property {boolean} byMe
+ * @property {string} playerId - the seat that played it, empty for the opening
+ * @property {number} points
+ * @property {number} syllables
+ * @property {boolean} opening - the seed word, played by neither side
+ * @property {Sense[]} meanings - what the word means, at most five; empty when
+ * the dictionary has none
+ * @property {PointPart[]} parts - how points was arrived at, one entry per
+ * non-zero term, summing to points; empty for the opening word
+ */
+
+/**
+ * @typedef {object} Sense
+ * @property {string} pos - Vietnamese part-of-speech label, empty when unknown
+ * @property {string} gloss - the definition, plain text
+ */
+
+/**
+ * @typedef {object} PointPart
+ * @property {number} kind - a PointKind enum value
+ * @property {number} value
+ */
+
+/**
+ * @typedef {object} PlayerSlot
+ * @property {string} playerId
+ * @property {string} name
+ * @property {boolean} isMe
+ * @property {boolean} isOwner
+ * @property {boolean} ready
+ * @property {boolean} connected
+ * @property {number} wins - games won since the room opened
+ */
+
+/**
+ * @typedef {object} PlayerScore
+ * @property {string} playerId
+ * @property {string} name
+ * @property {boolean} isMe
+ * @property {number} score
+ * @property {boolean} eliminated
+ * @property {boolean} connected
+ * @property {number} rank - final placing, 1 for the winner; 0 while in play
+ */
+
+/** @typedef {{ playerId: string, name: string, reason: number, suggestions: string[] }} Elimination */
+/** @typedef {{ playerId: string, name: string, isMe: boolean, reason: number }} LastOut */
+/** @typedef {{ n: number, fromMe: boolean, playerId: string, author: string, text: string, atMs: number }} ChatLine */
+/** @typedef {{ word: string, message: string, reason: number, suggestion: string }} Rejection */
+/** @typedef {{ iWon: boolean, reason: number, myScore: number, chainLength: number }} GameResult */
+
+/**
+ * Everything the screens read. One object, one source of truth: the store's
+ * own comment on `reset()` states the failure mode a slice design invites —
+ * merging fields selectively is how a client ends up believing a mixture of
+ * two states the server was never in — which is why this stays one typedef
+ * even though it is split across files.
+ * @typedef {object} GameState
+ * @property {'idle' | 'lobby' | 'playing' | 'over'} phase - Where the screen
+ * is. `lobby` is online-only: the room exists and its code can be shared,
+ * and it is where every game is agreed before it starts and returned to
+ * after it ends. RoomState deliberately does not move this — only
+ * GameStarted and GameOver do.
+ * @property {ChainEntry[]} chain
+ * @property {string[]} expanded - Words in the chain whose meaning is open.
+ * Client-only state, like the theme: the newest word opens on arrival and
+ * closes the one before it, and a click toggles any word.
+ * @property {string} currentSyllable
+ * @property {boolean} myTurn
+ * @property {number} deadlineMs
+ * @property {number} turnSeq
+ * @property {number} turnLimitMs
+ * @property {number} chainLength
+ * @property {string} nickname
+ * @property {string} roomCode
+ * @property {boolean} queued - Waiting in the quick-match queue for the next
+ * stranger who also asked. Ends on its own once a RoomState seats this
+ * connection somewhere.
+ * @property {PlayerSlot[]} roomPlayers - The room, exactly as the server last
+ * described it. Every field is server-owned. The recipient's own row is
+ * marked `isMe`, which is what the derived accessors below read.
+ * @property {boolean} canStart
+ * @property {number} maxPlayers
+ * @property {number} minPlayers
+ * @property {number} graceMs - How long a dropped seat is held for a reconnect.
+ * @property {PlayerScore[]} gamePlayers - The table of a running game, in
+ * turn order, and who is on turn.
+ * @property {string} turnPlayerId
+ * @property {PlayerScore[]} standings - The final table, best first: the
+ * player left standing, then the rest in reverse order of elimination.
+ * @property {Elimination | null} elimination - This player's own knockout,
+ * and nobody else's. Empty `suggestions` means it was a dead end, which is
+ * a different thing to say than "here is what you missed".
+ * @property {LastOut | null} lastOut - The last player to go out, whoever
+ * they were: what a spectator is shown. The client's own knockout is
+ * `elimination` above.
+ * @property {ChatLine[]} chat - The room's conversation, oldest first, capped
+ * at CHAT_WINDOW. Survives `reset()`; leaving the room is what clears it.
+ * @property {number} chatCount - How many messages this connection has been
+ * told about. `chat` is capped, so its length cannot say what a folded
+ * panel has not shown yet.
+ * @property {Rejection | null} rejection - `reason` is kept alongside the
+ * rendered message so the UI can decide whether reporting the word applies
+ * without re-deriving it from the text. `suggestion` is the one real word
+ * the input differs from by diacritics alone, empty when none applies.
+ * @property {string | null} claimError - A dead-end claim the server refused
+ * because a move still existed.
+ * @property {string | null} reportConfirmation - The confirmation text for
+ * the last word this session reported, once the server has acknowledged it.
+ * @property {GameResult | null} result - The finished game, from this
+ * player's side. The table it came with is `standings`.
+ * @property {string | null} error
+ */
+
+/** @returns {GameState} */
+export function initialState() {
+ return {
+ phase: 'idle',
+ chain: [],
+ expanded: [],
+ currentSyllable: '',
+ myTurn: false,
+ deadlineMs: 0,
+ turnSeq: 0,
+ turnLimitMs: 0,
+ chainLength: 0,
+
+ nickname: '',
+ roomCode: '',
+
+ queued: false,
+
+ roomPlayers: [],
+ canStart: false,
+ maxPlayers: 0,
+ minPlayers: 0,
+ graceMs: 0,
+
+ gamePlayers: [],
+ turnPlayerId: '',
+ standings: [],
+
+ elimination: null,
+ lastOut: null,
+
+ chat: [],
+ chatCount: 0,
+
+ rejection: null,
+ claimError: null,
+ reportConfirmation: null,
+ result: null,
+ error: null
+ };
+}
+
+/**
+ * Reads a word's senses off the wire.
+ * @param {WireSense[] | undefined} senses
+ * @returns {Sense[]}
+ */
+export function toSenses(senses) {
+ return (senses ?? []).map((s) => ({ pos: s.pos, gloss: s.gloss }));
+}
+
+/**
+ * Reads a move's score breakdown off the wire.
+ * @param {WirePointPart[] | undefined} parts
+ * @returns {PointPart[]}
+ */
+export function toParts(parts) {
+ return (parts ?? []).map((p) => ({ kind: p.kind, value: p.value }));
+}
+
+/**
+ * Reads one PlayerScore off the wire.
+ * @param {WirePlayerScore} p
+ * @returns {PlayerScore}
+ */
+export function toScore(p) {
+ return {
+ playerId: p.playerId,
+ name: p.name,
+ isMe: p.isMe,
+ score: p.score,
+ eliminated: p.eliminated,
+ connected: p.connected,
+ rank: p.rank
+ };
+}
+
+/**
+ * Reads one seat off the wire.
+ * @param {WirePlayerSlot} p
+ * @returns {PlayerSlot}
+ */
+export function toSlot(p) {
+ return {
+ playerId: p.playerId,
+ name: p.name,
+ isMe: p.isMe,
+ isOwner: p.isOwner,
+ ready: p.ready,
+ connected: p.connected,
+ wins: p.wins
+ };
+}
diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js
index 3c9ed21..e725cb0 100644
--- a/web/src/lib/stores/game.svelte.js
+++ b/web/src/lib/stores/game.svelte.js
@@ -1,233 +1,45 @@
-import { rejectMessage, errorMessage, fill, t } from '$lib/i18n/vi.js';
+import { applyTo, CHAT_WINDOW } from './game-apply.js';
+import { initialState } from './game-shape.js';
+
+export { CHAT_WINDOW };
/**
- * How many messages the panel holds. The same window the server keeps, so the
- * two can never disagree about what the conversation is.
+ * @typedef {import('./game-shape.js').GameState} GameState
+ * @typedef {import('./game-shape.js').ChainEntry} ChainEntry
+ * @typedef {import('./game-shape.js').PointPart} PointPart
+ * @typedef {import('./game-shape.js').PlayerSlot} PlayerSlot
+ * @typedef {import('./game-shape.js').PlayerScore} PlayerScore
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage
*/
-export const CHAT_WINDOW = 20;
-
-// Ordinal handed to each chat line as it arrives, for list keys. Never reset:
-// a replayed history must not reuse numbers a line still on screen holds.
-let chatOrdinal = 0;
/**
- * The game model is a projection of what the server sent. The client never
- * decides whether a word is valid, whose turn it is, or who won — it renders
- * the last message it received. That is what makes the bot and the online
- * modes the same screen.
- * @typedef {object} ChainEntry
- * @property {string} word - the canonical spelling
- * @property {string} typed - what the player actually typed, when it differed
- * @property {boolean} byMe
- * @property {string} playerId - the seat that played it, empty for the opening
- * @property {number} points
- * @property {number} syllables
- * @property {boolean} opening - the seed word, played by neither side
- * @property {Sense[]} meanings - what the word means, at most five; empty when
- * the dictionary has none
- * @property {PointPart[]} parts - how points was arrived at, one entry per
- * non-zero term, summing to points; empty for the opening word
- * @typedef {object} Sense
- * @property {string} pos - Vietnamese part-of-speech label, empty when unknown
- * @property {string} gloss - the definition, plain text
- * @typedef {object} PointPart
- * @property {number} kind - a PointKind enum value
- * @property {number} value
- * @typedef {object} PlayerSlot
- * @property {string} playerId
- * @property {string} name
- * @property {boolean} isMe
- * @property {boolean} isOwner
- * @property {boolean} ready
- * @property {boolean} connected
- * @property {number} wins - games won since the room opened
- * @typedef {object} PlayerScore
- * @property {string} playerId
- * @property {string} name
- * @property {boolean} isMe
- * @property {number} score
- * @property {boolean} eliminated
- * @property {boolean} connected
- * @property {number} rank - final placing, 1 for the winner; 0 while in play
+ * Fields a game ending, or a new one starting, does not change: they
+ * describe the room, not the game.
+ * @type {readonly (keyof GameState)[]}
*/
-
-/** @returns {any} */
-function initialState() {
- return {
- /**
- * Where the screen is. `lobby` is online-only: the room exists and its
- * code can be shared, and it is where every game is agreed before it
- * starts and returned to after it ends.
- *
- * RoomState deliberately does not move this. A game running is what
- * the phase is about, and only GameStarted and GameOver know that.
- * @type {'idle' | 'lobby' | 'playing' | 'over'}
- */
- phase: 'idle',
- /** @type {ChainEntry[]} */
- chain: [],
- /**
- * Words in the chain whose meaning is open. Client-only state, like the
- * theme: the newest word opens on arrival and closes the one before it,
- * and a click toggles any word, so any number may be open at once. A
- * list with set semantics rather than a Set, because $state proxies
- * arrays and not Sets.
- * @type {string[]}
- */
- expanded: [],
- currentSyllable: '',
- myTurn: false,
- deadlineMs: 0,
- turnSeq: 0,
- turnLimitMs: 0,
- chainLength: 0,
-
- nickname: '',
- roomCode: '',
-
- /**
- * Waiting in the quick-match queue for the next stranger who also
- * asked. Ends on its own once a `RoomState` seats this connection
- * somewhere, so nothing else has to clear it by hand.
- */
- queued: false,
-
- /**
- * The room, exactly as the server last described it. Every field is
- * server-owned: the client never decides who is seated, who owns the
- * room, who is ready, or whether a game may start.
- *
- * The recipient's own row is in `roomPlayers` like everybody else's,
- * marked `isMe`, which is what the derived accessors below read.
- * @type {PlayerSlot[]}
- */
- roomPlayers: [],
- canStart: false,
- /**
- * How many seats the room has and how many a game needs. Sent by the
- * server rather than compiled in here, so widening a room is a server
- * change alone.
- */
- maxPlayers: 0,
- minPlayers: 0,
- /** How long a seat is held for somebody who dropped. */
- graceMs: 0,
-
- /**
- * The table of a running game, in turn order, and who is on turn.
- * @type {PlayerScore[]}
- */
- gamePlayers: [],
- turnPlayerId: '',
- /**
- * The final table, best first: the player left standing, then the rest
- * in reverse order of elimination.
- * @type {PlayerScore[]}
- */
- standings: [],
-
- /**
- * This player's own knockout, and nobody else's. `suggestions` is what
- * the position still had when they lost it; empty means it was a dead
- * end, which is a different thing to say than "here is what you
- * missed".
- * @type {{ playerId: string, name: string, reason: number, suggestions: string[] } | null}
- */
- elimination: null,
- /**
- * The last player to go out, whoever they were. It is what a spectator
- * is shown; the client's own knockout is `elimination` above.
- * @type {{ playerId: string, name: string, isMe: boolean, reason: number } | null}
- */
- lastOut: null,
-
- /**
- * The room's conversation, oldest first, capped at CHAT_WINDOW. Chat
- * belongs to the room rather than to a game, so it survives reset();
- * leaving the room is what clears it.
- * `n` is a client-side ordinal for list keys: the server stamps lines at
- * millisecond resolution and one seat may send a burst, so a timestamp
- * is not unique.
- * @type {{ n: number, fromMe: boolean, playerId: string, author: string, text: string, atMs: number }[]}
- */
- chat: [],
- /**
- * How many messages this connection has been told about. The list above
- * is capped, so its length stops rising and cannot be used to work out
- * what a folded panel has not shown yet.
- *
- * A replayed history sets it to what that history holds rather than to
- * zero: a resync is not a reason to forget that three of those lines
- * arrived while the reader was looking away.
- */
- chatCount: 0,
-
- /**
- * `reason` is a RejectReason enum value, kept alongside the rendered
- * message so the UI can decide whether reporting the word applies
- * (only for NOT_IN_DICTIONARY) without re-deriving it from the text.
- * `suggestion` is the one real word the input differs from by
- * diacritics alone, empty when none applies.
- * @type {{ word: string, message: string, reason: number, suggestion: string } | null}
- */
- rejection: null,
- /**
- * A dead-end claim the server refused because a move still existed.
- * Shown inline near the input rather than in the top banner: it is
- * specific to the move just attempted, not a room-wide condition.
- * @type {string | null}
- */
- claimError: null,
- /**
- * The confirmation text for the last word this session reported, once
- * the server has acknowledged it.
- * @type {string | null}
- */
- reportConfirmation: null,
- /**
- * The finished game, from this player's side. The table it came with
- * is `standings`; this is the part about them.
- * @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null}
- */
- result: null,
- /** @type {string | null} */
- error: null
- };
-}
+const KEPT = [
+ 'nickname',
+ 'roomCode',
+ 'roomPlayers',
+ 'canStart',
+ 'maxPlayers',
+ 'minPlayers',
+ 'graceMs',
+ 'chat',
+ 'chatCount'
+];
/**
- * Reads a word's senses off the wire.
- * @param {any[] | undefined} senses
- * @returns {Sense[]}
+ * Copies the listed fields from `from` into `into`. A small generic instead
+ * of a `Set` plus an index write: both sides are known to be `GameState[K]`
+ * for whichever `K` is being copied, so this typechecks without a cast.
+ * @template {keyof GameState} K
+ * @param {GameState} into
+ * @param {GameState} from
+ * @param {readonly K[]} keys
*/
-function toSenses(senses) {
- return (senses ?? []).map((/** @type {any} */ s) => ({ pos: s.pos, gloss: s.gloss }));
-}
-
-/**
- * Reads a move's score breakdown off the wire.
- * @param {any[] | undefined} parts
- * @returns {PointPart[]}
- */
-function toParts(parts) {
- return (parts ?? []).map((/** @type {any} */ p) => ({ kind: p.kind, value: p.value }));
-}
-
-/**
- * Reads one PlayerScore off the wire.
- * @param {any} p
- * @returns {PlayerScore}
- */
-function toScore(p) {
- return {
- playerId: p.playerId,
- name: p.name,
- isMe: p.isMe,
- score: p.score,
- eliminated: p.eliminated,
- connected: p.connected,
- rank: p.rank
- };
+function carry(into, from, keys) {
+ for (const k of keys) into[k] = from[k];
}
/**
@@ -241,29 +53,11 @@ export function createGameStore() {
* Returns the model to its pre-game shape, keeping the identity fields and
* the room. A game ending, or a new one starting, does not change which
* room this is or who is in it — the server says so with its own message.
- *
- * A plain lookup table, built once and never mutated or read reactively —
- * SvelteSet is for state a template tracks, which this never is.
*/
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
- const kept = new Set([
- 'nickname',
- 'roomCode',
- 'roomPlayers',
- 'canStart',
- 'maxPlayers',
- 'minPlayers',
- 'graceMs',
- 'chat',
- 'chatCount'
- ]);
-
function reset() {
const fresh = initialState();
- for (const key of Object.keys(fresh)) {
- if (kept.has(key)) continue;
- state[key] = fresh[key];
- }
+ carry(fresh, state, KEPT);
+ Object.assign(state, fresh);
}
/**
@@ -271,248 +65,26 @@ export function createGameStore() {
* is what leaving and being kicked have in common.
*/
function leave() {
- const fresh = initialState();
- for (const key of Object.keys(fresh)) {
- state[key] = fresh[key];
- }
+ Object.assign(state, initialState());
}
/**
- * Applies one ServerMessage. Every arm of the oneof is handled here and
- * nowhere else, so adding a message to the protocol has exactly one place
- * in the client that has to learn about it.
- * @param {any} msg - a decoded ServerMessage
+ * Applies one ServerMessage. Every arm of the oneof is handled in
+ * `game-apply.js` and nowhere else, so adding a message to the protocol
+ * has exactly one place in the client that has to learn about it.
+ *
+ * Wrapped here rather than in `applyTo` itself: a throw partway through a
+ * case would otherwise leave the `$state` proxy half-mutated on screen —
+ * `roomState` sets `queued`/`roomCode`/`canStart` before mapping
+ * `players`, for instance — so the whole application is treated as one
+ * step, logged and discarded rather than left half done.
+ * @param {ServerMessage} msg
*/
function apply(msg) {
- const { case: kind, value } = msg.payload;
-
- switch (kind) {
- case 'welcome':
- // The server sanitizes the requested name, so what it returns
- // is the only name safe to display — never the raw input.
- state.nickname = value.acceptedNickname;
- break;
-
- case 'roomState':
- // A room existing is proof the wait is over, whether or not a
- // quickMatchStatus already said so.
- state.queued = false;
- // One snapshot, applied wholesale. Merging fields selectively
- // is how a client ends up believing a mixture of two states
- // the server was never in.
- state.roomCode = value.roomCode;
- state.canStart = value.canStart;
- state.maxPlayers = value.maxPlayers;
- state.minPlayers = value.minPlayers;
- state.graceMs = value.graceMs;
- state.roomPlayers = value.players.map((/** @type {any} */ p) => ({
- playerId: p.playerId,
- name: p.name,
- isMe: p.isMe,
- isOwner: p.isOwner,
- ready: p.ready,
- connected: p.connected,
- wins: p.wins
- }));
- // The lobby is where a room sits when no game is on. `over`
- // keeps its result panel, which the lobby appears beneath.
- if (state.phase === 'idle') state.phase = 'lobby';
- break;
-
- case 'gameStarted':
- // reset() clears the readiness that led here, along with the
- // last game's board and its knockouts.
- reset();
- state.phase = 'playing';
- state.chain = [
- {
- word: value.openingWord,
- typed: '',
- byMe: false,
- playerId: '',
- points: 0,
- syllables: 0,
- opening: true,
- meanings: toSenses(value.openingMeanings),
- parts: []
- }
- ];
- // The opening word is the newest word there is.
- state.expanded = [value.openingWord];
- state.currentSyllable = value.currentSyllable;
- state.myTurn = value.myTurn;
- state.deadlineMs = Number(value.deadlineUnixMs);
- state.turnSeq = value.turnSeq;
- state.turnLimitMs = value.turnLimitMs;
- state.chainLength = 1;
- state.gamePlayers = value.players.map(toScore);
- state.turnPlayerId = value.turnPlayerId;
- break;
-
- case 'turnUpdate': {
- const played = value.played;
- // A turn update with no word is an elimination moving the turn
- // on: the syllable and the chain survive the player who could
- // not answer them, so there is nothing to append.
- if (played) {
- // The newest word takes over the open panel from the one
- // before it. Words the player opened by hand stay open.
- const previous = state.chain[state.chain.length - 1]?.word;
- state.chain.push({
- word: played.word,
- typed: played.typed,
- byMe: played.byMe,
- playerId: played.playerId,
- points: played.points,
- syllables: played.syllables,
- opening: false,
- meanings: toSenses(played.meanings),
- parts: toParts(played.parts)
- });
- state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== previous);
- if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
- }
- state.currentSyllable = value.currentSyllable;
- state.myTurn = value.myTurn;
- state.deadlineMs = Number(value.deadlineUnixMs);
- state.turnSeq = value.turnSeq;
- state.chainLength = value.chainLength;
- state.gamePlayers = value.players.map(toScore);
- state.turnPlayerId = value.turnPlayerId;
- // An accepted move answers the previous rejection — and only
- // an accepted move does. A wordless update is somebody being
- // eliminated, which says nothing about the word this player
- // was just refused, and wiping the reason off their screen is
- // one player's exit costing another the only explanation they
- // had.
- if (played) {
- state.rejection = null;
- state.reportConfirmation = null;
- }
- // A false dead-end claim is about the position this update
- // just moved past, however the turn moved.
- state.claimError = null;
- break;
- }
-
- case 'moveRejected':
- state.rejection = {
- word: value.word,
- message: rejectMessage(value.reason, state.currentSyllable),
- reason: value.reason,
- suggestion: value.suggestion ?? ''
- };
- // A new rejection has nothing reported against it yet.
- state.reportConfirmation = null;
- break;
-
- case 'wordReported':
- state.reportConfirmation = fill(t.wordReported, { word: value.word });
- break;
-
- case 'playerEliminated':
- state.lastOut = {
- playerId: value.playerId,
- name: value.name,
- isMe: value.isMe,
- reason: value.reason
- };
- // Only the player who went out is sent suggestions, and only
- // they have a use for them: they describe the position that
- // beat them, which is nobody else's position.
- if (value.isMe) {
- state.myTurn = false;
- state.elimination = {
- playerId: value.playerId,
- name: value.name,
- reason: value.reason,
- suggestions: value.suggestions ?? []
- };
- }
- break;
-
- case 'gameOver': {
- state.phase = 'over';
- state.myTurn = false;
- state.standings = value.standings.map(toScore);
- const mine = state.standings.find((/** @type {PlayerScore} */ p) => p.isMe);
- state.result = {
- iWon: value.iWon,
- reason: value.reason,
- myScore: mine?.score ?? 0,
- chainLength: value.chainLength
- };
- break;
- }
-
- case 'chatMessage':
- state.chat.push({
- n: ++chatOrdinal,
- fromMe: value.fromMe,
- playerId: value.playerId,
- author: value.author,
- text: value.text,
- // int64 on the wire, which the runtime hands over as a
- // bigint. Nothing downstream expects one.
- atMs: Number(value.sentUnixMs)
- });
- state.chatCount++;
- // Trimmed to the server's window, so a long conversation and a
- // replayed one are the same list.
- if (state.chat.length > CHAT_WINDOW) {
- state.chat = state.chat.slice(-CHAT_WINDOW);
- }
- break;
-
- case 'chatHistory':
- // A snapshot replaces; it never merges. It is also what a
- // client arriving in a new room is given, so a conversation
- // cannot outlive the room it was had in.
- state.chat = value.messages.map((/** @type {any} */ m) => ({
- n: ++chatOrdinal,
- fromMe: m.fromMe,
- playerId: m.playerId,
- author: m.author,
- text: m.text,
- atMs: Number(m.sentUnixMs)
- }));
- state.chatCount = state.chat.length;
- break;
-
- case 'error':
- // Two of them also end this player's membership of the room, so
- // the model has to stop describing one. Set after, because
- // leaving clears everything including the message.
- if (value.code === 'kicked' || value.code === 'room_idle_closed') leave();
- // A false dead-end claim is answered next to the input, not in
- // the top banner: it is about the move just attempted, not a
- // room-wide condition every screen has to show.
- if (value.code === 'not_a_dead_end') {
- state.claimError = errorMessage(value.code);
- break;
- }
- // A match the server could not open leaves nobody queued, and
- // the only frame that says so is this refusal.
- if (value.code === 'server_full' || value.code === 'room_start_failed' || value.code === 'server_restarting') {
- state.queued = false;
- }
- state.error = errorMessage(value.code);
- break;
-
- case 'quickMatchStatus':
- state.queued = value.queued;
- break;
-
- case 'pong':
- // Handled by the transport, which owns the clock offset.
- break;
-
- default:
- // A message this build does not know. Silence would make the
- // next contract addition look like a network problem, so say
- // so once rather than dropping it invisibly.
- console.warn('unhandled server message', kind);
- break;
+ try {
+ applyTo(state, msg, { reset, leave });
+ } catch (err) {
+ console.error('failed to apply server message', msg.payload.case, err);
}
}
@@ -529,7 +101,7 @@ export function createGameStore() {
* @returns {PlayerSlot | null}
*/
get me() {
- return state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.isMe) ?? null;
+ return state.roomPlayers.find((p) => p.isMe) ?? null;
},
get isOwner() {
return this.me?.isOwner ?? false;
@@ -548,7 +120,7 @@ export function createGameStore() {
/** This player's score in the game on screen, finished or not. */
get myScore() {
const table = state.phase === 'over' ? state.standings : state.gamePlayers;
- return table.find((/** @type {PlayerScore} */ p) => p.isMe)?.score ?? 0;
+ return table.find((p) => p.isMe)?.score ?? 0;
},
/**
* How many games a seat has won since the room opened. Read off the
@@ -558,9 +130,7 @@ export function createGameStore() {
* @returns {number}
*/
winsOf(playerId) {
- return (
- state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.wins ?? 0
- );
+ return state.roomPlayers.find((p) => p.playerId === playerId)?.wins ?? 0;
},
/**
* Which seat a player is in, 1-based, or 0 for nobody. It is what the
@@ -571,9 +141,7 @@ export function createGameStore() {
*/
seatIndexOf(playerId) {
if (!playerId) return 0;
- const at = state.roomPlayers.findIndex(
- (/** @type {PlayerSlot} */ p) => p.playerId === playerId
- );
+ const at = state.roomPlayers.findIndex((p) => p.playerId === playerId);
return at < 0 ? 0 : at + 1;
},
/**
@@ -582,7 +150,7 @@ export function createGameStore() {
* @returns {PlayerSlot[]}
*/
get awayPlayers() {
- return state.roomPlayers.filter((/** @type {PlayerSlot} */ p) => !p.isMe && !p.connected);
+ return state.roomPlayers.filter((p) => !p.isMe && !p.connected);
},
/**
* The name behind a seat id, for the chain and the board. Falls back to
@@ -594,8 +162,8 @@ export function createGameStore() {
nameOf(playerId) {
const from = state.gamePlayers.length ? state.gamePlayers : state.standings;
return (
- from.find((/** @type {PlayerScore} */ p) => p.playerId === playerId)?.name ??
- state.roomPlayers.find((/** @type {PlayerSlot} */ p) => p.playerId === playerId)?.name ??
+ from.find((p) => p.playerId === playerId)?.name ??
+ state.roomPlayers.find((p) => p.playerId === playerId)?.name ??
''
);
},
@@ -624,7 +192,7 @@ export function createGameStore() {
*/
toggleMeaning(word) {
if (state.expanded.includes(word)) {
- state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== word);
+ state.expanded = state.expanded.filter((w) => w !== word);
} else {
state.expanded.push(word);
}