From c4502f2793a5a058e1cef470095007604bee424f Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:03:03 +0700
Subject: [PATCH 1/8] refactor(web): split the game store into shape, apply and
store files
Type GameState for real instead of returning any from initialState(), which
made every game.state.* read in every component unchecked. apply() now
switches on payload.case so the oneof narrows, and is wrapped in try/catch so
a throw partway through a case cannot leave a half-mutated snapshot on
screen. Also key chain meanings by position instead of gloss, since the
dictionary gives no gloss-uniqueness guarantee, and gate the word field's
turn-seed effect on the same connection check `enabled` already uses so a
reconnect cannot seed a field the player cannot submit from.
---
...wer-260921-1529-web-architecture-review.md | 505 ++++++++++++++++
web/src/lib/components/ChainHistory.svelte | 10 +-
web/src/lib/components/WordInput.svelte | 8 +-
web/src/lib/stores/game-apply.js | 268 +++++++++
web/src/lib/stores/game-shape.js | 227 ++++++++
web/src/lib/stores/game.svelte.js | 548 ++----------------
6 files changed, 1072 insertions(+), 494 deletions(-)
create mode 100644 plans/reports/code-reviewer-260921-1529-web-architecture-review.md
create mode 100644 web/src/lib/stores/game-apply.js
create mode 100644 web/src/lib/stores/game-shape.js
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. -->
{/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);
}
From 70ae09d16b0ac0ed047ec8562f6d74dd25078180 Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:07:02 +0700
Subject: [PATCH 2/8] refactor(web): type the wire instead of passing any
across it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use the generated ServerMessage/ClientMessage union everywhere a decoded
message crosses a function boundary, so a typo in a payload field is a build
error instead of undefined at runtime. Clears every jsdoc/reject-any-type
warning in src/ and all but one in tests/ — room-code.test.js keeps one to
deliberately call normalizeRoomCode(undefined) against its documented
string-only signature, which is the point of that test.
Also fixes the flaky e2e helper: playingPair now waits for the guest's seat
(joinRoomSeated) before readyAndStart, and readyAndStart asserts the guest's
own ready row before touching Start, since a SetReady send can silently drop
while the socket is not open and nothing retries it.
---
web/e2e/helpers.js | 9 ++++++++-
web/e2e/pvp-game.spec.js | 4 +++-
web/e2e/socket-cut.js | 4 ++--
web/src/lib/stores/bot-session.svelte.js | 6 +++---
web/src/lib/ws/client.js | 17 +++++++++++------
web/src/lib/ws/connection.svelte.js | 4 +++-
web/tests/game-store.test.js | 6 +++---
web/tests/game-wire.test.js | 11 ++++++++---
web/tests/ws-client.test.js | 12 ++++++------
9 files changed, 47 insertions(+), 26 deletions(-)
diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js
index 30f8aed..3e7450e 100644
--- a/web/e2e/helpers.js
+++ b/web/e2e/helpers.js
@@ -120,12 +120,19 @@ export async function say(page, text) {
* Takes a seated pair from their lobby into a game: the guest readies, the
* owner starts. Nothing begins on its own now, so every online test that is
* about a game goes through here.
+ *
+ * Waits for each guest's own row to show ready before touching Start: the
+ * click only asks the server, and asserting the owner's button next proves
+ * nothing about whether the guest's SetReady actually left the client —
+ * `send()` returns false while the socket is not open and nothing retries
+ * it, which is exactly the gap that made this assertion flake.
* @param {import('@playwright/test').Page} owner
- * @param {import('@playwright/test').Page} guest
+ * @param {import('@playwright/test').Page[]} guests
*/
export async function readyAndStart(owner, ...guests) {
for (const guest of guests) {
await guest.getByTestId('ready').click();
+ await expect(guest.getByTestId('my-ready')).toHaveText('Đã sẵn sàng');
}
const start = owner.getByTestId('start-game');
await expect(start).toBeEnabled();
diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js
index b4ebfd6..2a13fa6 100644
--- a/web/e2e/pvp-game.spec.js
+++ b/web/e2e/pvp-game.spec.js
@@ -74,7 +74,9 @@ async function joinRoomSeated(page, nickname, code) {
async function playingPair(browser) {
const pair = await twoPlayers(browser);
const code = await createRoom(pair.host, 'Minh');
- await joinRoom(pair.guest, 'Lan', code);
+ // Seated, not merely joined: readyAndStart clicks the guest's own ready
+ // row next, which does not exist until the seat does.
+ await joinRoomSeated(pair.guest, 'Lan', code);
await readyAndStart(pair.host, pair.guest);
const {
lead,
diff --git a/web/e2e/socket-cut.js b/web/e2e/socket-cut.js
index 707493f..ae899f4 100644
--- a/web/e2e/socket-cut.js
+++ b/web/e2e/socket-cut.js
@@ -13,9 +13,9 @@
* @param {import('@playwright/test').Page} page
*/
export async function cuttableSocket(page) {
- /** @type {any} */
+ /** @type {import('@playwright/test').WebSocketRoute | null} */
let live = null;
- /** @type {any} */
+ /** @type {import('@playwright/test').WebSocketRoute | null} */
let upstream = null;
let blocked = false;
diff --git a/web/src/lib/stores/bot-session.svelte.js b/web/src/lib/stores/bot-session.svelte.js
index 730cf37..8642271 100644
--- a/web/src/lib/stores/bot-session.svelte.js
+++ b/web/src/lib/stores/bot-session.svelte.js
@@ -20,7 +20,7 @@ export function createBotSession({ start }) {
const state = $state({
/** @type {number | null} The difficulty waiting to be requested. */
pending: null,
- /** @type {object | null} The result already counted towards a record. */
+ /** @type {{ myScore: number } | null} The result already counted towards a record. */
scored: null
});
@@ -63,7 +63,7 @@ export function createBotSession({ start }) {
* Identity of the result object is the guard rather than a boolean, so
* re-entering the screen with the same result cannot score it twice and
* a genuinely new result is never mistaken for the old one.
- * @param {object | null} result - GameOver as the store holds it
+ * @param {{ myScore: number } | null} result - GameOver as the store holds it
* @param {number} difficulty
* @param {{ recordScore: (difficulty: number, score: number) => boolean }} settings
* @returns {boolean} whether this game set a new record
@@ -71,7 +71,7 @@ export function createBotSession({ start }) {
score(result, difficulty, settings) {
if (!result || state.scored === result) return false;
state.scored = result;
- return settings.recordScore(difficulty, /** @type {any} */ (result).myScore);
+ return settings.recordScore(difficulty, result.myScore);
},
/** Forgets the scored result so a new game can set a record again. */
diff --git a/web/src/lib/ws/client.js b/web/src/lib/ws/client.js
index e807e0c..aa0a7ed 100644
--- a/web/src/lib/ws/client.js
+++ b/web/src/lib/ws/client.js
@@ -2,6 +2,11 @@ import { fromBinary, toBinary } from '@bufbuild/protobuf';
import { ClientMessageSchema, ServerMessageSchema } from '$lib/proto/noitu/v1/game_pb.js';
import { hello, ping } from './messages.js';
+/**
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage
+ * @typedef {import('$lib/proto/noitu/v1/game_pb.js').ServerMessage} ServerMessage
+ */
+
/** Connection states surfaced to the UI. */
export const Status = {
CONNECTING: 'connecting',
@@ -97,14 +102,14 @@ export function hasStoredSession() {
* @param {object} options
* @param {() => string} options.nickname - read at each connect, so a name
* changed between attempts is the one the server is told about
- * @param {(msg: any) => void} options.onMessage
+ * @param {(msg: ServerMessage) => void} options.onMessage
* @param {(status: string) => void} [options.onStatus]
* @param {string} [options.url]
* @param {(url: string) => WebSocket} [options.socketFactory]
* @param {() => number} [options.now]
* @param {() => number} [options.random] - jitter source
* @param {typeof setTimeout} [options.schedule]
- * @param {(id: any) => void} [options.cancel]
+ * @param {(id: ReturnType) => void} [options.cancel]
*/
export function createClient({
nickname,
@@ -123,9 +128,9 @@ export function createClient({
// Set by a deliberate close and by a server error that reconnecting cannot
// fix. Both mean the same thing to onclose: do not come back.
let stopReconnecting = false;
- /** @type {any} */
+ /** @type {ReturnType | null} */
let reconnectTimer = null;
- /** @type {any} */
+ /** @type {ReturnType | null} */
let pingTimer = null;
let clockOffsetMs = 0;
let status = Status.CLOSED;
@@ -266,7 +271,7 @@ export function createClient({
* Two messages are the transport's own business before the UI sees them:
* Welcome carries the token a reconnect needs, and Pong is the clock probe.
* Both are still forwarded, because the UI shows the accepted nickname.
- * @param {any} msg
+ * @param {ServerMessage} msg
*/
function intercept(msg) {
const payload = msg.payload;
@@ -294,7 +299,7 @@ export function createClient({
}
}
- /** @param {any} msg */
+ /** @param {ClientMessage} msg */
function send(msg) {
if (!socket || socket.readyState !== 1) return false;
socket.send(toBinary(ClientMessageSchema, msg));
diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js
index b7519db..aae6d2d 100644
--- a/web/src/lib/ws/connection.svelte.js
+++ b/web/src/lib/ws/connection.svelte.js
@@ -2,6 +2,8 @@ import { Status, createClient, hasStoredSession } from './client.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
+/** @typedef {import('$lib/proto/noitu/v1/game_pb.js').ClientMessage} ClientMessage */
+
/**
* One socket for the whole app.
*
@@ -33,7 +35,7 @@ export function connect() {
* Deliberately does not open the socket: a caller that has not connected yet
* has nothing queued to resume, and auto-connecting here would reopen the
* connection during teardown.
- * @param {any} msg - a ClientMessage
+ * @param {ClientMessage} msg
* @returns {boolean}
*/
export function send(msg) {
diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js
index 5f8ea7c..74228a5 100644
--- a/web/tests/game-store.test.js
+++ b/web/tests/game-store.test.js
@@ -621,7 +621,7 @@ describe('chat', () => {
store.apply(line({ text: 'một' }));
store.apply(line({ text: 'hai', fromMe: true }));
- expect(store.state.chat.map((/** @type {any} */ m) => m.text)).toEqual(['một', 'hai']);
+ expect(store.state.chat.map((m) => m.text)).toEqual(['một', 'hai']);
expect(typeof store.state.chat[0].atMs).toBe('number');
expect(store.state.chat[0].atMs).toBe(1756998000123);
});
@@ -632,7 +632,7 @@ describe('chat', () => {
// An author the server cleared: the seat goes with the name.
store.apply(line({ text: 'của ai', playerId: '', author: '' }));
- expect(store.state.chat.map((/** @type {any} */ m) => m.playerId)).toEqual(['p1', '']);
+ expect(store.state.chat.map((m) => m.playerId)).toEqual(['p1', '']);
});
it('stops at the window the server keeps, so the two cannot disagree', () => {
@@ -658,7 +658,7 @@ describe('chat', () => {
store.apply(history);
store.apply(history);
- expect(store.state.chat.map((/** @type {any} */ m) => m.text)).toEqual(['a', 'b']);
+ expect(store.state.chat.map((m) => m.text)).toEqual(['a', 'b']);
});
it('survives a game starting: the conversation belongs to the room', () => {
diff --git a/web/tests/game-wire.test.js b/web/tests/game-wire.test.js
index 8262a47..97b0e3f 100644
--- a/web/tests/game-wire.test.js
+++ b/web/tests/game-wire.test.js
@@ -24,7 +24,12 @@ const fixtureDir = fileURLToPath(new URL('../../proto/testdata', import.meta.url
const fixtures = readdirSync(fixtureDir).filter((f) => f.endsWith('.bin'));
-/** Decode a fixture by name, choosing the schema from its client_/server_ prefix. */
+/**
+ * Decode a fixture by name, choosing the schema from its client_/server_ prefix.
+ * @param {string} name
+ * @returns {import('../src/lib/proto/noitu/v1/game_pb.js').ClientMessage
+ * | import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage}
+ */
function decode(name) {
const bytes = readFileSync(join(fixtureDir, `${name}.bin`));
const schema = name.startsWith('client_') ? ClientMessageSchema : ServerMessageSchema;
@@ -73,7 +78,7 @@ describe('generated wire types', () => {
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
expect(over.payload.value.iWon).toBe(false);
// The final table, in the order the server ranked it.
- expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]);
+ expect(over.payload.value.standings.map((p) => p.rank)).toEqual([1, 2, 3]);
const out = decode('server_player_eliminated');
// The only repeated string in the contract, and the one the losing
@@ -139,7 +144,7 @@ describe('generated wire types', () => {
const turn = decode('server_turn_update');
const parts = turn.payload.value.played.parts;
expect(parts.length).toBeGreaterThan(0);
- const sum = parts.reduce((total, /** @type {any} */ p) => total + p.value, 0);
+ const sum = parts.reduce((total, p) => total + p.value, 0);
expect(sum).toBe(turn.payload.value.played.points);
});
diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js
index c874b8f..22ea461 100644
--- a/web/tests/ws-client.test.js
+++ b/web/tests/ws-client.test.js
@@ -51,7 +51,7 @@ class FakeSocket {
this.onopen();
}
- /** @param {any} serverMessage */
+ /** @param {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage} serverMessage */
deliver(serverMessage) {
this.onmessage({ data: toBinary(ServerMessageSchema, serverMessage).buffer });
}
@@ -104,7 +104,7 @@ function serverMsg(kind, value) {
function setup(options = {}) {
/** @type {FakeSocket[]} */
const sockets = [];
- /** @type {any[]} */
+ /** @type {import('../src/lib/proto/noitu/v1/game_pb.js').ServerMessage[]} */
const received = [];
/** @type {string[]} */
const statuses = [];
@@ -380,7 +380,7 @@ describe('handshake ordering', () => {
// ahead of it.
/** @type {string[]} */
const order = [];
- /** @type {any} */
+ /** @type {FakeSocket} */
let socket;
const client = createClient({
nickname: () => 'Minh',
@@ -408,7 +408,7 @@ describe('handshake ordering', () => {
});
describe('a handshake the server refuses', () => {
- /** @param {any} h */
+ /** @param {ReturnType} h */
function refuseVersion(h) {
h.client.connect();
h.last().open();
@@ -452,7 +452,7 @@ describe('reconnecting on demand', () => {
// an attempt now. What it must not do is open a second socket, or retry a
// handshake the server has already refused outright.
- /** @param {any} h */
+ /** @param {ReturnType} h */
function dropAfterOpen(h) {
h.client.connect();
h.last().open();
@@ -568,7 +568,7 @@ describe('a socket that dies without closing', () => {
/**
* Fires the ping timer `ticks` times, advancing the clock by one interval
* each time — a healthy tab whose timers are running on schedule.
- * @param {any} h
+ * @param {ReturnType} h
* @param {number} ticks
* @param {number} start
*/
From eace5940da921272b192cbaf0606e94ad1bb8cdb Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:17:23 +0700
Subject: [PATCH 3/8] refactor(web): extract the online screen's request
machine into a store
Move the join/resume/quick-match/leave state machine out of
routes/online/+page.svelte into stores/room-session.svelte.js, modelled on
bot-session.svelte.js: no DOM, no runes beyond $state, so the resume time-box
is something Vitest can drive directly. The page keeps layout, timers and
wiring; the store keeps what the player asked for.
fix(web): time-box the resume latch and stop leaking a left room's session
A stale resume token used to get silence from the server, leaving `resuming`
stuck true and every button on the join form disabled with no way out but a
reload. `resuming` now clears five seconds after the socket opens if nothing
has answered by then, same as it already does on an explicit error (which
also covers a server new enough to send `session_not_resumable` instead of
staying quiet).
leave() now calls forgetSession(), matching the page-teardown path: without
it, deliberately leaving a room left the token behind, and the next load of
/online tried to resume into the room the player had just walked out of.
fix(web): retry cancelQueue, leave and lobby actions instead of dropping them
send() returns false while the socket is down, and cancelQueue/leave ignored
that return value while ready/start/kick only reported it as a dead-looking
button. All five now hold the request and resend it once the socket reopens,
the way join/create already do, via room-session's held-action slot. Lobby's
"reconnecting" banner is now driven by that held action instead of a local
flag that never noticed a background retry had succeeded.
Also: the lobby's chat panel now reopens once a game ends (phase 'over')
instead of staying folded for the rest of the room's life after the first
game, since phase never actually revisits 'lobby' on its own.
---
web/src/lib/components/Lobby.svelte | 27 +--
web/src/lib/stores/room-session.svelte.js | 192 ++++++++++++++++++
web/src/routes/online/+page.svelte | 232 +++++++++++++---------
3 files changed, 346 insertions(+), 105 deletions(-)
create mode 100644 web/src/lib/stores/room-session.svelte.js
diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte
index 5bce95e..d22e4d3 100644
--- a/web/src/lib/components/Lobby.svelte
+++ b/web/src/lib/components/Lobby.svelte
@@ -17,17 +17,21 @@
* above already carries the connection state and the away banners.
*
* The callbacks report whether the request actually reached the server. A
- * socket that has just dropped answers `false`, and a button that silently
- * did nothing is the fastest way to make a room look dead.
+ * socket that has just dropped answers `false` and holds the request for
+ * the caller to retry once the socket reopens — `actionHeld` is that
+ * retry showing here, so the banner clears itself once it lands rather
+ * than being a one-shot flag this component would have no way to know
+ * had gone stale.
* @type {{
* compact?: boolean,
+ * actionHeld?: boolean,
* onready: (ready: boolean) => boolean,
* onstart: () => boolean,
* onkick: (playerId: string) => boolean,
* onleave: () => void
* }}
*/
- let { compact = false, onready, onstart, onkick, onleave } = $props();
+ let { compact = false, actionHeld = false, onready, onstart, onkick, onleave } = $props();
/** How long an armed kick waits before it goes back to being safe. */
const ARM_MS = 4000;
@@ -55,22 +59,13 @@
let armedKick = $state('');
/** @type {ReturnType} */
let armTimer;
- // Set when a request could not go out at all, which is a different thing
- // from the server refusing it — that arrives as game.state.error.
- let unsent = $state(false);
-
- /** @param {boolean} sent */
- function report(sent) {
- unsent = !sent;
- return sent;
- }
/** @param {string} playerId */
function armOrKick(playerId) {
if (armedKick === playerId) {
clearTimeout(armTimer);
armedKick = '';
- report(onkick(playerId));
+ onkick(playerId);
return;
}
armedKick = playerId;
@@ -201,7 +196,7 @@
aria-label={t.dismiss}>×
- {:else if unsent}
+ {:else if actionHeld}
{t.reconnecting}
{/if}
@@ -212,7 +207,7 @@
class="primary"
disabled={!s.canStart || offline}
data-testid="start-game"
- onclick={() => report(onstart())}
+ onclick={() => onstart()}
>
{t.startGame}
@@ -223,7 +218,7 @@
class:on={game.isReady}
disabled={offline}
data-testid="ready"
- onclick={() => report(onready(!game.isReady))}
+ onclick={() => onready(!game.isReady)}
>
{game.isReady ? t.unready : t.ready}
diff --git a/web/src/lib/stores/room-session.svelte.js b/web/src/lib/stores/room-session.svelte.js
new file mode 100644
index 0000000..bb8750e
--- /dev/null
+++ b/web/src/lib/stores/room-session.svelte.js
@@ -0,0 +1,192 @@
+/**
+ * Owns the /online screen's request machine: what the player has asked for
+ * that the socket has not yet carried, from opening a room through leaving
+ * one. Modelled on bot-session.svelte.js and for the same reason — a request
+ * is a thing the player did, not a condition inferred from the board, so it
+ * is stored as one rather than re-derived from `game.state`.
+ *
+ * No DOM and no runes beyond `$state`: every timer (the resume time-box, the
+ * stall timer, the quick-match elapsed clock) is a plain `setTimeout` or
+ * `setInterval` owned by the page's own `$effect`s, which call back into the
+ * plain setters here. That is what makes this testable under Vitest with no
+ * Svelte runtime involved, exactly as `ws/client.js` already is.
+ */
+
+/**
+ * What the player asked for, held until the socket can carry it.
+ * @typedef {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' }} RoomRequest
+ */
+
+/**
+ * A request made from inside a room that the socket refused to carry.
+ * Replacing rather than queuing: a later action is a later expression of the
+ * same intent (readying and then leaving before reconnecting means the
+ * leave is what should happen), and every one of these is safe to resend —
+ * `SetReady`, `StartGame`, `KickPlayer`, `LeaveRoom` and `CancelQuickMatch`
+ * are all refused rather than misapplied if they no longer make sense by the
+ * time they land.
+ * @typedef {{ kind: 'cancelQueue' } | { kind: 'leaveRoom' } | { kind: 'setReady', ready: boolean } | { kind: 'startGame' } | { kind: 'kickPlayer', playerId: string }} RoomAction
+ */
+
+export function createRoomSession() {
+ const state = $state({
+ /** @type {RoomRequest | null} */
+ pending: null,
+ // True while the only reason this screen has a socket is to reclaim a
+ // game it might no longer be able to reclaim.
+ resuming: false,
+ // An invite link arrived before this player had a name.
+ needName: false,
+ // A held request that has been waiting on a socket for longer than a
+ // player will believe.
+ stalled: false,
+ // The resume timed out (or was refused) rather than succeeding. Shown
+ // instead of leaving the join form's own "connecting" label the only
+ // sign anything happened.
+ resumeFailed: false,
+ // Whole seconds spent in the quick-match queue, for the waiting
+ // panel's elapsed time and the bot nudge. Owned here rather than left
+ // as a raw interval in the page, alongside everything else this
+ // screen is waiting on.
+ queuedForS: 0,
+ /** @type {RoomAction | null} */
+ heldAction: null
+ });
+
+ return {
+ state,
+
+ /**
+ * Queues a request to open or join a room. Nothing is sent until the
+ * socket is open, which the caller still has to drive with `connect()`
+ * and `flush()` — this only records the intent.
+ * @param {RoomRequest} req
+ */
+ request(req) {
+ state.pending = req;
+ state.resuming = false;
+ state.needName = false;
+ state.stalled = false;
+ state.resumeFailed = false;
+ },
+
+ /** Drops a queued request without sending it. */
+ clearPending() {
+ state.pending = null;
+ },
+
+ /**
+ * Holds a join behind a resume already in progress, without touching
+ * the other latches `request()` resets — the resume owns those while
+ * it runs, and is what the held join is waiting on.
+ * @param {RoomRequest} req
+ */
+ holdPendingJoin(req) {
+ state.pending = req;
+ },
+
+ /** @param {boolean} value */
+ setNeedName(value) {
+ state.needName = value;
+ },
+
+ /**
+ * Sends the queued request if there is one and the socket can carry
+ * it. Nothing goes out while a resume is in flight: the seat this tab
+ * is reclaiming may be in the very room the held request names.
+ * @param {boolean} isOpen
+ * @param {{
+ * create: () => boolean,
+ * join: (code: string) => boolean,
+ * quickMatch: () => boolean
+ * }} senders
+ * @returns {boolean} whether a request was sent
+ */
+ flush(isOpen, senders) {
+ if (!state.pending || !isOpen || state.resuming) return false;
+ const req = state.pending;
+ const sent =
+ req.kind === 'create'
+ ? senders.create()
+ : req.kind === 'quickMatch'
+ ? senders.quickMatch()
+ : senders.join(req.code);
+ if (sent) state.pending = null;
+ return sent;
+ },
+
+ /** Marks a resume attempt as starting, optionally behind a held join. */
+ startResume() {
+ state.resuming = true;
+ },
+
+ /**
+ * The resume worked, so nothing that happens from here is its fault —
+ * and an invite code held behind it has been answered by arriving in a
+ * room.
+ */
+ noteRoom() {
+ state.resuming = false;
+ state.pending = null;
+ state.resumeFailed = false;
+ },
+
+ /**
+ * A resume that the server refused, or that ran out the client's own
+ * patience for. Neither is something the player did, so the token is
+ * dropped and an invite code held behind it is either spent (the
+ * player already has a name) or turned into asking for one.
+ * @param {boolean} named
+ */
+ noteResumeFailed(named) {
+ if (!state.resuming) return;
+ state.resuming = false;
+ state.resumeFailed = true;
+ if (state.pending && !named) {
+ state.needName = true;
+ state.pending = null;
+ }
+ },
+
+ /** @param {boolean} value */
+ setStalled(value) {
+ state.stalled = value;
+ },
+
+ resetQueued() {
+ state.queuedForS = 0;
+ },
+
+ tickQueued() {
+ state.queuedForS += 1;
+ },
+
+ /**
+ * Holds a request the socket refused, replacing whatever this screen
+ * was already waiting to retry: a later action is a later statement of
+ * intent, and every action here is safe to resend regardless of order.
+ * @param {RoomAction} action
+ */
+ holdAction(action) {
+ state.heldAction = action;
+ },
+
+ /** Drops a held action without sending it, for a screen being torn down. */
+ clearAction() {
+ state.heldAction = null;
+ },
+
+ /**
+ * Resends a held action once the socket can carry it.
+ * @param {boolean} isOpen
+ * @param {(action: RoomAction) => boolean} dispatch
+ * @returns {boolean} whether the held action was sent
+ */
+ flushAction(isOpen, dispatch) {
+ if (!state.heldAction || !isOpen) return false;
+ const sent = dispatch(state.heldAction);
+ if (sent) state.heldAction = null;
+ return sent;
+ }
+ };
+}
diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte
index d67c541..731361d 100644
--- a/web/src/routes/online/+page.svelte
+++ b/web/src/routes/online/+page.svelte
@@ -9,9 +9,10 @@
import Lobby from '$lib/components/Lobby.svelte';
import NicknameInput from '$lib/components/NicknameInput.svelte';
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
- import { fill, t } from '$lib/i18n/vi.js';
+ import { errorMessage, fill, t } from '$lib/i18n/vi.js';
import { scrollBehavior } from '$lib/motion.js';
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
+ import { createRoomSession } from '$lib/stores/room-session.svelte.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
import {
@@ -40,20 +41,11 @@
} from '$lib/ws/connection.svelte.js';
/**
- * What the player asked for, held until the socket can carry it. Same shape
- * as the bot screen's request latch and for the same reason: a request is
- * something the player did, not a condition to be re-derived from the board.
- * @type {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' } | null}
+ * The join/resume/quick-match/leave machine. Extracted to its own store —
+ * see room-session.svelte.js — so the resume time-box below is something
+ * Vitest can drive directly instead of only through a mounted page.
*/
- let pending = $state(null);
-
- /**
- * How long a quick match has been waiting, in whole seconds. Client-only
- * and approximate on purpose — this is a "still looking" indicator, not
- * the turn clock, so it is timed off the device rather than the server's
- * estimated time.
- */
- let queuedForS = $state(0);
+ const session = createRoomSession();
/**
* How long the wait runs before the screen offers the bot instead. A
@@ -70,6 +62,17 @@
*/
const STALL_MS = 5000;
+ /**
+ * How long `resuming` waits, once the socket is actually open, before the
+ * join form comes back on its own. The server answering an unknown resume
+ * token used to mean silence — no Welcome, no error — which left every
+ * button on this screen reading "Đang kết nối…" forever, recoverable only
+ * by a reload that reproduced the same dead end. A server new enough to
+ * answer with `session_not_resumable` clears this sooner, through the
+ * ordinary error path below; this is the backstop for one that cannot.
+ */
+ const RESUME_TIMEOUT_MS = 5000;
+
/**
* Where the two-column layout starts. The media queries in the styles
* below are the same decision expressed in CSS, so the two must agree: the
@@ -105,8 +108,15 @@
/** @type {HTMLElement | undefined} */
let talkPane = $state();
+ // Folded going into a game, on a narrow screen where the two would crowd
+ // each other; unfolded again once it ends, since the compact lobby that
+ // appears beneath the result is the same "waiting in a room" situation
+ // the chat is open for everywhere else. Phase never actually revisits
+ // 'lobby' after the first game — the room goes over → (next start) →
+ // playing directly — so folding was permanent after one game without this.
$effect(() => {
if (game.state.phase === 'playing') chatFolded = true;
+ else if (game.state.phase === 'over') chatFolded = false;
});
function openChat() {
@@ -116,14 +126,6 @@
let codeInput = $state(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
let codeError = $state('');
- // True while the only reason this screen has a socket is to reclaim a game
- // it might no longer be able to reclaim.
- let resuming = $state(false);
- // An invite link arrived before this player had a name. Asking is one extra
- // tap, and the alternative is being seated as "Người chơi" with no way to
- // fix it from inside the room.
- let needName = $state(false);
- let stalled = $state(false);
const inviteCode = $derived(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
const playing = $derived(game.state.phase === 'playing' || game.state.phase === 'over');
@@ -135,11 +137,7 @@
// The resume worked, so nothing that happens from here is its fault — and
// an invite code held behind it has been answered by arriving in a room.
$effect(() => {
- if (inRoom)
- untrack(() => {
- resuming = false;
- pending = null;
- });
+ if (inRoom) untrack(() => session.noteRoom());
});
// Owns the socket while this screen is on, exactly as the bot screen does.
@@ -168,8 +166,8 @@
// given their old one back to — a red banner on a board that had
// in fact been restored correctly. The code is held instead, and
// only spent if the resume is refused.
- resuming = true;
- if (isRoomCode(code)) pending = { kind: 'join', code };
+ session.startResume();
+ if (isRoomCode(code)) session.holdPendingJoin({ kind: 'join', code });
connect();
} else if (isRoomCode(code)) {
if (named) {
@@ -177,7 +175,7 @@
} else {
// Held, not sent. The name field is already on this screen and
// the code is already in its field, so this is one button.
- needName = true;
+ session.setNeedName(true);
}
}
});
@@ -205,17 +203,23 @@
// Leaving mid-wait is leaving the queue too: nobody is left to pair
// with a tab that has gone.
if (game.state.queued) send(cancelQuickMatch());
- pending = null;
+ session.clearPending();
+ session.clearAction();
disconnect();
game.reset();
game.clearChat();
};
});
- // Held requests go out once the handshake has landed.
+ // Held requests go out once the handshake has landed — both the one that
+ // opens or joins a room, and any lobby action the socket refused while it
+ // was down.
$effect(() => {
const open = connection.status === Status.OPEN;
- untrack(() => flush(open));
+ untrack(() => {
+ flush(open);
+ session.flushAction(open, dispatchAction);
+ });
});
// Counts up while queued, for the waiting panel's elapsed time and the
@@ -224,11 +228,11 @@
// clock.
$effect(() => {
if (!game.state.queued) {
- queuedForS = 0;
+ session.resetQueued();
return;
}
- queuedForS = 0;
- const id = setInterval(() => (queuedForS += 1), 1000);
+ session.resetQueued();
+ const id = setInterval(() => session.tickQueued(), 1000);
return () => clearInterval(id);
});
@@ -237,44 +241,55 @@
// backoff cycles between "reconnecting" and "connecting" indefinitely and
// neither of them is news.
$effect(() => {
- const waiting = !!pending && connection.status !== Status.OPEN;
+ const waiting = !!session.state.pending && connection.status !== Status.OPEN;
if (!waiting) {
- stalled = false;
+ session.setStalled(false);
return;
}
- const timer = setTimeout(() => (stalled = true), STALL_MS);
+ const timer = setTimeout(() => session.setStalled(true), STALL_MS);
+ return () => clearTimeout(timer);
+ });
+
+ // The bound this screen puts on how long a resume may run once the socket
+ // is actually open. An older server answers a stale token with silence
+ // rather than an error, which without this left `resuming` stuck true
+ // forever — every button on the join form disabled, and the `stalled`
+ // banner never firing because it only watches a socket that never opened,
+ // not a handshake that opened and then went quiet.
+ $effect(() => {
+ if (!(session.state.resuming && connection.status === Status.OPEN)) return;
+ const timer = setTimeout(() => {
+ untrack(() => {
+ session.noteResumeFailed(named);
+ forgetSession();
+ if (!session.state.needName) flush(connection.status === Status.OPEN);
+ });
+ }, RESUME_TIMEOUT_MS);
return () => clearTimeout(timer);
});
// A resume that the server cannot honour is not something the player did.
// Reporting it would open the lobby with a red banner about a game they
// have already left behind, so the token is dropped quietly instead — and
- // an invite code held behind the resume is spent now.
+ // an invite code held behind the resume is spent now. A server new enough
+ // to answer a stale token with `session_not_resumable` lands here, ahead
+ // of the time-box above.
$effect(() => {
- const failed = resuming && !!game.state.error;
+ const failed = session.state.resuming && !!game.state.error;
untrack(() => {
if (!failed) return;
- resuming = false;
game.clearError();
forgetSession();
- if (pending && !named) {
- // The link was for somebody who has still not given a name.
- needName = true;
- pending = null;
- return;
- }
- flush(connection.status === Status.OPEN);
+ session.noteResumeFailed(named);
+ if (!session.state.needName) flush(connection.status === Status.OPEN);
});
});
- /** @param {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' }} req */
+ /** @param {import('$lib/stores/room-session.svelte.js').RoomRequest} req */
function request(req) {
codeError = '';
- resuming = false;
- needName = false;
- stalled = false;
game.clearError();
- pending = req;
+ session.request(req);
// The handshake carries the nickname as it stands now, which is why the
// connection waits until the player has actually asked for a room.
connect();
@@ -283,23 +298,35 @@
/** @param {boolean} isOpen */
function flush(isOpen) {
- // Nothing goes out while a resume is in flight: the seat this tab is
- // reclaiming may be in the very room the held code names.
- if (!pending || !isOpen || resuming) return;
- // Cleared only once the socket has taken it, so a request made during a
- // reconnect is carried by the next open connection rather than lost.
- let sent;
- switch (pending.kind) {
- case 'create':
- sent = send(createRoom());
- break;
- case 'quickMatch':
- sent = send(quickMatch());
- break;
+ session.flush(isOpen, {
+ create: () => send(createRoom()),
+ join: (code) => send(joinRoom(code)),
+ quickMatch: () => send(quickMatch())
+ });
+ }
+
+ /**
+ * Resends a lobby action the socket refused the first time. Every one of
+ * these is safe to resend regardless of what happened in between: the
+ * server refuses whichever no longer apply rather than misapplying them.
+ * @param {import('$lib/stores/room-session.svelte.js').RoomAction} action
+ * @returns {boolean}
+ */
+ function dispatchAction(action) {
+ switch (action.kind) {
+ case 'cancelQueue':
+ return send(cancelQuickMatch());
+ case 'leaveRoom':
+ return send(leaveRoom());
+ case 'setReady':
+ return send(setReady(action.ready));
+ case 'startGame':
+ return send(startGame());
+ case 'kickPlayer':
+ return send(kickPlayer(action.playerId));
default:
- sent = send(joinRoom(pending.code));
+ return false;
}
- if (sent) pending = null;
}
function join() {
@@ -309,7 +336,7 @@
return;
}
if (!named) {
- needName = true;
+ session.setNeedName(true);
return;
}
request({ kind: 'join', code });
@@ -325,8 +352,9 @@
/** Withdraws from the pairing queue without leaving the page. */
function cancelQueue() {
- send(cancelQuickMatch());
- pending = null;
+ const sent = send(cancelQuickMatch());
+ if (!sent) session.holdAction({ kind: 'cancelQueue' });
+ session.clearPending();
}
function goHome() {
@@ -347,12 +375,16 @@
* @returns {boolean}
*/
function ready(ready) {
- return send(setReady(ready));
+ const sent = send(setReady(ready));
+ if (!sent) session.holdAction({ kind: 'setReady', ready });
+ return sent;
}
/** @returns {boolean} */
function start() {
- return send(startGame());
+ const sent = send(startGame());
+ if (!sent) session.holdAction({ kind: 'startGame' });
+ return sent;
}
/**
@@ -362,13 +394,20 @@
function kick(playerId) {
// The lobby arms this with a second press of the same button; a native
// confirm() would block the frame loop the countdown runs on.
- return send(kickPlayer(playerId));
+ const sent = send(kickPlayer(playerId));
+ if (!sent) session.holdAction({ kind: 'kickPlayer', playerId });
+ return sent;
}
function leave() {
- send(leaveRoom());
+ const sent = send(leaveRoom());
+ if (!sent) session.holdAction({ kind: 'leaveRoom' });
game.leave();
- pending = null;
+ session.clearPending();
+ // Matches the page-teardown path: leaving deliberately must not leave
+ // a token behind for the next load of /online to resume with — the
+ // player just walked out of this room on purpose.
+ forgetSession();
}
/** @param {string} text */
@@ -439,11 +478,11 @@
the next game is agreed in the lobby below exactly as the
last one was. -->
-
+
{/snippet}
{:else}
-
+
{/if}
@@ -475,7 +514,7 @@
- {#if needName}
+ {#if session.state.needName}
{t.quickMatchNudge}
{t.quickMatchNudgeLink}
@@ -506,12 +555,17 @@
send a real CreateRoom, and the fifth one came back as "you are
creating rooms too quickly" to a player who thought they had tapped
nothing at all. -->
-
{codeError || t.roomCodeHint}
From f0c2334228bbf41176641c54489ff762262762a0 Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:18:53 +0700
Subject: [PATCH 4/8] test(web): cover the room-session request machine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Join/create latch and retry-on-refusal, the resume latch clearing on
noteRoom() and on noteResumeFailed() (both the explicit-error and the
time-box paths), quick-match queued-to-seated, and the held-action slot for
cancelQueue/leave/ready/start/kick — replace-not-queue semantics, retry on
refusal, and drop on teardown.
---
web/tests/room-session.test.js | 265 +++++++++++++++++++++++++++++++++
1 file changed, 265 insertions(+)
create mode 100644 web/tests/room-session.test.js
diff --git a/web/tests/room-session.test.js b/web/tests/room-session.test.js
new file mode 100644
index 0000000..eb0914d
--- /dev/null
+++ b/web/tests/room-session.test.js
@@ -0,0 +1,265 @@
+// The online screen's request machine, extracted so its two failure modes —
+// a stale resume token that never gets an answer, and a lobby action the
+// socket refused — are things this file can prove directly, without a
+// mounted page or a real socket.
+
+import { describe, expect, it } from 'vitest';
+import { createRoomSession } from '../src/lib/stores/room-session.svelte.js';
+
+/** Records what reached the socket for each request kind. */
+function senders(accepted = true) {
+ /** @type {string[]} */
+ const sent = [];
+ return {
+ sent,
+ create: () => {
+ sent.push('create');
+ return accepted;
+ },
+ join: (/** @type {string} */ code) => {
+ sent.push(`join:${code}`);
+ return accepted;
+ },
+ quickMatch: () => {
+ sent.push('quickMatch');
+ return accepted;
+ }
+ };
+}
+
+describe('joining or creating a room', () => {
+ it('holds the request until the socket is open', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.request({ kind: 'create' });
+ expect(session.flush(false, s)).toBe(false);
+ expect(s.sent).toEqual([]);
+
+ expect(session.flush(true, s)).toBe(true);
+ expect(s.sent).toEqual(['create']);
+ expect(session.state.pending).toBeNull();
+ });
+
+ it('sends a join with its room code', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.request({ kind: 'join', code: 'K7M2QP' });
+ session.flush(true, s);
+
+ expect(s.sent).toEqual(['join:K7M2QP']);
+ });
+
+ it('holds a refused request so the next open connection carries it', () => {
+ const refused = senders(false);
+ const session = createRoomSession();
+
+ session.request({ kind: 'create' });
+ expect(session.flush(true, refused)).toBe(false);
+ expect(session.flush(true, refused)).toBe(false);
+
+ expect(refused.sent).toEqual(['create', 'create']);
+ expect(session.state.pending).not.toBeNull();
+ });
+
+ it('keeps only the latest request when asked twice before sending', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.request({ kind: 'create' });
+ session.request({ kind: 'quickMatch' });
+ session.flush(true, s);
+
+ expect(s.sent).toEqual(['quickMatch']);
+ });
+
+ it('clears every latch a fresh request opens with', () => {
+ const session = createRoomSession();
+ session.setNeedName(true);
+ session.setStalled(true);
+
+ session.request({ kind: 'create' });
+
+ expect(session.state.needName).toBe(false);
+ expect(session.state.stalled).toBe(false);
+ expect(session.state.resuming).toBe(false);
+ });
+});
+
+describe('resuming a stored session', () => {
+ it('holds a request behind the resume and refuses to send it while resuming', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.startResume();
+ session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
+
+ expect(session.flush(true, s)).toBe(false);
+ expect(s.sent).toEqual([]);
+ });
+
+ it('spends the held join once the resume lands in a room', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.startResume();
+ session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
+ session.noteRoom();
+
+ expect(session.state.resuming).toBe(false);
+ // Arriving in a room answers the held code too — it is not sent
+ // afterwards as a second, redundant join.
+ expect(session.state.pending).toBeNull();
+ expect(session.flush(true, s)).toBe(false);
+ expect(s.sent).toEqual([]);
+ });
+
+ it('times out to the join form instead of staying stuck forever', () => {
+ // The scenario C1 in the review describes: an older server answers a
+ // stale token with silence, never with an error.
+ const session = createRoomSession();
+
+ session.startResume();
+ session.noteResumeFailed(/* named */ true);
+
+ expect(session.state.resuming).toBe(false);
+ expect(session.state.resumeFailed).toBe(true);
+ });
+
+ it('does nothing if the resume already resolved before the timer fired', () => {
+ const session = createRoomSession();
+
+ session.startResume();
+ session.noteRoom();
+ session.noteResumeFailed(true);
+
+ // noteRoom() already cleared resuming; the stale timer callback must
+ // not reopen the failure state on top of a resume that succeeded.
+ expect(session.state.resumeFailed).toBe(false);
+ });
+
+ it('asks for a name instead of spending an invite code held behind a failed resume', () => {
+ const session = createRoomSession();
+
+ session.startResume();
+ session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
+ session.noteResumeFailed(/* named */ false);
+
+ expect(session.state.needName).toBe(true);
+ expect(session.state.pending).toBeNull();
+ });
+
+ it('keeps a held join when the player already has a name, so it flushes next', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.startResume();
+ session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
+ session.noteResumeFailed(/* named */ true);
+
+ expect(session.state.needName).toBe(false);
+ expect(session.flush(true, s)).toBe(true);
+ expect(s.sent).toEqual(['join:K7M2QP']);
+ });
+});
+
+describe('quick match', () => {
+ it('goes from queued to seated the same way a plain request does', () => {
+ const s = senders();
+ const session = createRoomSession();
+
+ session.request({ kind: 'quickMatch' });
+ expect(session.flush(true, s)).toBe(true);
+ expect(s.sent).toEqual(['quickMatch']);
+
+ session.resetQueued();
+ session.tickQueued();
+ session.tickQueued();
+ expect(session.state.queuedForS).toBe(2);
+
+ // RoomState arriving is what a match found looks like from here.
+ session.noteRoom();
+ expect(session.state.pending).toBeNull();
+ });
+
+ it('counts seconds only from the moment counting starts', () => {
+ const session = createRoomSession();
+ session.tickQueued();
+ expect(session.state.queuedForS).toBe(1);
+ session.resetQueued();
+ expect(session.state.queuedForS).toBe(0);
+ });
+});
+
+describe('held room actions', () => {
+ /** @param {boolean} accepted */
+ function dispatcher(accepted = true) {
+ /** @type {import('../src/lib/stores/room-session.svelte.js').RoomAction[]} */
+ const sent = [];
+ return {
+ sent,
+ /** @param {import('../src/lib/stores/room-session.svelte.js').RoomAction} action */
+ dispatch: (action) => {
+ sent.push(action);
+ return accepted;
+ }
+ };
+ }
+
+ it('does nothing when nothing is held', () => {
+ const d = dispatcher();
+ const session = createRoomSession();
+
+ expect(session.flushAction(true, d.dispatch)).toBe(false);
+ expect(d.sent).toEqual([]);
+ });
+
+ it('resends a held action once the socket reopens', () => {
+ const d = dispatcher();
+ const session = createRoomSession();
+
+ session.holdAction({ kind: 'setReady', ready: true });
+ expect(session.flushAction(false, d.dispatch)).toBe(false);
+ expect(d.sent).toEqual([]);
+
+ expect(session.flushAction(true, d.dispatch)).toBe(true);
+ expect(d.sent).toEqual([{ kind: 'setReady', ready: true }]);
+ expect(session.state.heldAction).toBeNull();
+ });
+
+ it('keeps retrying a held action the server keeps refusing', () => {
+ const d = dispatcher(false);
+ const session = createRoomSession();
+
+ session.holdAction({ kind: 'startGame' });
+ expect(session.flushAction(true, d.dispatch)).toBe(false);
+ expect(session.flushAction(true, d.dispatch)).toBe(false);
+
+ expect(d.sent).toEqual([{ kind: 'startGame' }, { kind: 'startGame' }]);
+ });
+
+ it('replaces an unset held action with the player\'s later intent', () => {
+ // Readying, then leaving before either reaches the server: only the
+ // leave should still be waiting to go out.
+ const d = dispatcher();
+ const session = createRoomSession();
+
+ session.holdAction({ kind: 'setReady', ready: true });
+ session.holdAction({ kind: 'leaveRoom' });
+ session.flushAction(true, d.dispatch);
+
+ expect(d.sent).toEqual([{ kind: 'leaveRoom' }]);
+ });
+
+ it('drops a held action when the screen is torn down before it can be sent', () => {
+ const d = dispatcher();
+ const session = createRoomSession();
+
+ session.holdAction({ kind: 'cancelQueue' });
+ session.clearAction();
+
+ expect(session.flushAction(true, d.dispatch)).toBe(false);
+ expect(d.sent).toEqual([]);
+ });
+});
From f0bcb6084a969eb7bd1822bbd20187c354e2642c Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:21:40 +0700
Subject: [PATCH 5/8] refactor(web): extract ArmedButton for the three
press-twice controls
Resign, claim-dead-end and kick each duplicated the same arm/disarm timer
and disarm-on-disable effect. ArmedButton.svelte owns that once, plus the
a11y gap none of the three closed: aria-pressed carries the armed state to
assistive tech, since a screen reader announces a control's name on focus,
not on the in-place label swap the first press used to be silent about.
Kick arms per seat now rather than sharing one Lobby-level slot, which was
an implementation detail of the old shared state rather than a stated rule.
---
web/src/lib/components/ArmedButton.svelte | 84 +++++++++++++++++
web/src/lib/components/GameBoard.svelte | 108 +++++-----------------
web/src/lib/components/Lobby.svelte | 47 +++-------
3 files changed, 121 insertions(+), 118 deletions(-)
create mode 100644 web/src/lib/components/ArmedButton.svelte
diff --git a/web/src/lib/components/ArmedButton.svelte b/web/src/lib/components/ArmedButton.svelte
new file mode 100644
index 0000000..cc4be46
--- /dev/null
+++ b/web/src/lib/components/ArmedButton.svelte
@@ -0,0 +1,84 @@
+
+
+
+ {#if children}
+ {@render children()}
+ {:else}
+ {armed ? confirmLabel : label}
+ {/if}
+
diff --git a/web/src/lib/components/GameBoard.svelte b/web/src/lib/components/GameBoard.svelte
index 9e7fae9..a7ffa83 100644
--- a/web/src/lib/components/GameBoard.svelte
+++ b/web/src/lib/components/GameBoard.svelte
@@ -1,4 +1,5 @@
@@ -237,15 +175,13 @@
the current syllable, which only means something on this
player's own turn — unlike resign, there is no "not yet" state
worth showing for it off turn. -->
-
- {claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
-
+ onconfirm={onclaimdeadend}
+ />
{/if}
{#if game.state.claimError}
@@ -265,15 +201,13 @@
that grows, and a button under it walks off the bottom of the screen
exactly as the game gets long enough to want to give up on. -->
{#if game.state.phase === 'playing' && !game.iAmOut}
-
- {arming ? t.resignSure : t.resign}
-
+ onconfirm={onresign}
+ />
{/if}
@@ -418,10 +352,14 @@
text-align: center;
}
+ /* :global(): these are ArmedButton's own , not one this
+ component's template renders directly, so Svelte's scoped-style
+ attribute never lands on it. */
+
/* Right of the board and away from the input: giving up is the one thing
here nobody should hit by accident while typing. Danger coloured because
it ends the game, subordinate because it is not the way to play it. */
- .resign {
+ :global(.resign) {
align-self: flex-end;
/* Below the 44px the rest of the controls keep, deliberately: this is
the one button here nobody is trying to hit, it takes two presses to
@@ -438,20 +376,20 @@
transition: background-color 150ms ease-out;
}
- .resign:hover:enabled {
+ :global(.resign:hover:enabled) {
background: var(--danger-soft);
}
/* Off turn: still there, so the way out of the game does not appear and
disappear under the player's thumb every handover, but plainly not the
thing to press yet. */
- .resign:disabled {
+ :global(.resign:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
/* Armed, and saying so: the second press is the one that ends the game. */
- .resign.arming {
+ :global(.resign.arming) {
border-color: var(--danger);
background: var(--danger-soft);
font-weight: 600;
@@ -461,7 +399,7 @@
the syllable on screen right now, so it reads as part of answering it
rather than as a way out of the game. Secondary weight either way — it
is not the way to play a turn, just a shortcut past an empty one. */
- .claim-dead-end {
+ :global(.claim-dead-end) {
align-self: flex-start;
min-height: 32px;
padding: var(--space-1) var(--space-3);
@@ -473,16 +411,16 @@
transition: background-color 150ms ease-out;
}
- .claim-dead-end:hover:enabled {
+ :global(.claim-dead-end:hover:enabled) {
background: var(--surface-alt);
}
- .claim-dead-end:disabled {
+ :global(.claim-dead-end:disabled) {
border-color: var(--border);
color: var(--text-muted);
}
- .claim-dead-end.arming {
+ :global(.claim-dead-end.arming) {
border-color: var(--accent);
background: var(--accent-soft);
font-weight: 600;
diff --git a/web/src/lib/components/Lobby.svelte b/web/src/lib/components/Lobby.svelte
index d22e4d3..ab84413 100644
--- a/web/src/lib/components/Lobby.svelte
+++ b/web/src/lib/components/Lobby.svelte
@@ -1,4 +1,5 @@
@@ -135,17 +114,16 @@
blocks the frame loop, and this is the same control asking
again rather than a second one appearing. -->
{#if game.isOwner && !player.isMe}
- armOrKick(player.playerId)}
+ testid={`kick-${player.playerId}`}
+ onconfirm={() => onkick(player.playerId)}
>
×
-
+
{/if}
@@ -354,8 +332,11 @@
* a quarter of a screen to a four-seat lobby that is already long. The
* touch target is the full 44 all the same, expanded out of the flow by a
* pseudo-element so the row keeps its height.
+ *
+ * :global(): ArmedButton renders its own , which this component's
+ * scoped-style attribute never reaches.
*/
- .kick {
+ :global(.kick) {
position: relative;
width: 36px;
height: 36px;
@@ -369,17 +350,17 @@
line-height: 1;
}
- .kick::after {
+ :global(.kick::after) {
content: '';
position: absolute;
inset: -4px;
}
- .kick:disabled {
+ :global(.kick:disabled) {
opacity: 0.35;
}
- .kick.arming {
+ :global(.kick.arming) {
border-color: var(--danger);
background: var(--danger-soft);
color: var(--danger);
From 69310a3b93d6eccfbd6a8427d1236985c59b0687 Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:24:20 +0700
Subject: [PATCH 6/8] test(web): mount ChatPanel under jsdom for fold and
unread accounting
Cover the fold toggle, the unread badge counting only while folded and
clearing on open, the recount after a fold-and-reread cycle, and send/clear.
Needed a resolve.conditions fix in vite.config.js gated on process.env.VITEST
so Vitest picks svelte's client runtime instead of its SSR one for mount();
vite dev and vite build are untouched.
---
web/tests/chat-panel.test.js | 170 +++++++++++++++++++++++++++++++++++
web/vite.config.js | 6 ++
2 files changed, 176 insertions(+)
create mode 100644 web/tests/chat-panel.test.js
diff --git a/web/tests/chat-panel.test.js b/web/tests/chat-panel.test.js
new file mode 100644
index 0000000..7b8c6f1
--- /dev/null
+++ b/web/tests/chat-panel.test.js
@@ -0,0 +1,170 @@
+// @vitest-environment jsdom
+
+// ChatPanel's unread accounting is what broke CI once already (the review
+// that asked for this file cites it by name), and until now nothing mounted
+// the component to prove it. jsdom is already a devDependency; Svelte 5
+// components compiled by the vite plugin mount directly under it with no
+// extra library.
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mount, unmount, flushSync } from 'svelte';
+import { create } from '@bufbuild/protobuf';
+import ChatPanel from '../src/lib/components/ChatPanel.svelte';
+import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
+import { game } from '../src/lib/stores/game.svelte.js';
+
+/** @param {{ author?: string, text?: string, fromMe?: boolean }} [fields] */
+function receiveLine({ author = 'Lan', text = 'chào', fromMe = false } = {}) {
+ game.apply(
+ create(ServerMessageSchema, {
+ payload: {
+ case: 'chatMessage',
+ value: { fromMe, author, text, playerId: 'p2', sentUnixMs: 1n }
+ }
+ })
+ );
+}
+
+/** @param {ConstructorParameters[0]['props']} props */
+function renderChatPanel(props) {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+ const component = mount(ChatPanel, { target, props });
+ flushSync();
+ return { target, component };
+}
+
+beforeEach(() => {
+ game.clearChat();
+ // jsdom does not implement scrollTo; the panel calls it to keep the
+ // newest line in view, which is not what this file is testing.
+ Element.prototype.scrollTo = () => {};
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('folding', () => {
+ it('starts folded when collapsible, with no log or input on screen', () => {
+ const { target } = renderChatPanel({ collapsible: true, onsend: () => {} });
+
+ expect(target.querySelector('[data-testid="chat-toggle"]')).not.toBeNull();
+ expect(target.querySelector('[data-testid="chat-log"]')).toBeNull();
+ expect(target.querySelector('[data-testid="chat-input"]')).toBeNull();
+ });
+
+ it('opens on a toggle press and shows the log', () => {
+ receiveLine();
+ const { target } = renderChatPanel({ collapsible: true, onsend: () => {} });
+
+ /** @type {HTMLButtonElement | null} */
+ const toggle = target.querySelector('[data-testid="chat-toggle"]');
+ toggle?.click();
+ flushSync();
+
+ expect(target.querySelector('[data-testid="chat-log"]')).not.toBeNull();
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true');
+ });
+
+ it('never folds when not collapsible, regardless of the toggle', () => {
+ const { target } = renderChatPanel({ collapsible: false, onsend: () => {} });
+
+ expect(target.querySelector('[data-testid="chat-toggle"]')).toBeNull();
+ expect(target.querySelector('[data-testid="chat-input"]')).not.toBeNull();
+ });
+});
+
+describe('unread count', () => {
+ it('counts a line that arrives while folded', () => {
+ const { target } = renderChatPanel({ collapsible: true, onsend: () => {} });
+
+ receiveLine({ text: 'một' });
+ flushSync();
+
+ expect(target.querySelector('[data-testid="chat-unread"]')?.textContent).toContain('1');
+ });
+
+ it('clears to zero once the panel is opened', () => {
+ receiveLine({ text: 'một' });
+ const { target } = renderChatPanel({ collapsible: true, onsend: () => {} });
+ flushSync();
+
+ /** @type {HTMLButtonElement | null} */
+ const toggle = target.querySelector('[data-testid="chat-toggle"]');
+ toggle?.click();
+ flushSync();
+
+ expect(target.querySelector('[data-testid="chat-unread"]')).toBeNull();
+ });
+
+ it('does not count anything while the panel is already open', () => {
+ const { target } = renderChatPanel({ collapsible: false, onsend: () => {} });
+
+ receiveLine({ text: 'một' });
+ flushSync();
+
+ expect(target.querySelector('[data-testid="chat-unread"]')).toBeNull();
+ });
+
+ it('resumes counting once folded again after having been read', () => {
+ const { target, component } = renderChatPanel({ collapsible: true, onsend: () => {} });
+ /** @type {HTMLButtonElement | null} */
+ const toggle = target.querySelector('[data-testid="chat-toggle"]');
+
+ receiveLine({ text: 'một' });
+ toggle?.click(); // opens, marks it read
+ flushSync();
+ toggle?.click(); // folds again
+ flushSync();
+ receiveLine({ text: 'hai' });
+ flushSync();
+
+ expect(target.querySelector('[data-testid="chat-unread"]')?.textContent).toContain('1');
+ unmount(component);
+ });
+});
+
+describe('sending', () => {
+ it('reports the typed text and clears the field on submit', () => {
+ /** @type {string[]} */
+ const sent = [];
+ const { target } = renderChatPanel({ collapsible: false, onsend: (text) => sent.push(text) });
+
+ /** @type {HTMLInputElement | null} */
+ const input = target.querySelector('[data-testid="chat-input"]');
+ if (input) {
+ input.value = 'xin chào';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ flushSync();
+
+ /** @type {HTMLButtonElement | null} */
+ const send = target.querySelector('[data-testid="chat-send"]');
+ expect(send?.disabled).toBe(false);
+ target.querySelector('form')?.requestSubmit();
+ flushSync();
+
+ expect(sent).toEqual(['xin chào']);
+ expect(input?.value).toBe('');
+ });
+
+ it('refuses a message that is only whitespace', () => {
+ /** @type {string[]} */
+ const sent = [];
+ const { target } = renderChatPanel({ collapsible: false, onsend: (text) => sent.push(text) });
+
+ /** @type {HTMLInputElement | null} */
+ const input = target.querySelector('[data-testid="chat-input"]');
+ if (input) {
+ input.value = ' ';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ flushSync();
+
+ expect(/** @type {HTMLButtonElement | null} */ (target.querySelector('[data-testid="chat-send"]'))?.disabled).toBe(
+ true
+ );
+ expect(sent).toEqual([]);
+ });
+});
diff --git a/web/vite.config.js b/web/vite.config.js
index 38a297e..bcb58a2 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -6,6 +6,12 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
+ // Vitest does not build client/server bundles the way `vite build` does,
+ // so without this the "svelte" package resolves its server-rendering
+ // entry point even for a component test under jsdom, and `mount()`
+ // throws "not available on the server". `VITEST` is set by Vitest
+ // itself, so `vite dev` and `vite build` are unaffected.
+ resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
server: {
// Dev runs Vite and the Go binary on different ports, so the socket has
// to be proxied. That keeps the client's URL logic identical in both
From cf48d270dee188d3406814d564b737a482d6af34 Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:28:36 +0700
Subject: [PATCH 7/8] test(web): mount WordInput and GameBoard under jsdom
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WordInput: the turn seed fires once per turn and is gated on the connection
(regression test for C6), a reconnect blip mid-turn does not reseed over
what the player typed, a rejection's suggestion fills the field on click,
and the player's own in-turn composition is left alone.
GameBoard: the chat pill renders only when onchatopen is passed, which is
the actual contract between the bot and online routes — a bot game simply
never passes it.
---
web/tests/game-board.test.js | 103 ++++++++++++++++++++
web/tests/word-input.test.js | 179 +++++++++++++++++++++++++++++++++++
2 files changed, 282 insertions(+)
create mode 100644 web/tests/game-board.test.js
create mode 100644 web/tests/word-input.test.js
diff --git a/web/tests/game-board.test.js b/web/tests/game-board.test.js
new file mode 100644
index 0000000..f75214d
--- /dev/null
+++ b/web/tests/game-board.test.js
@@ -0,0 +1,103 @@
+// @vitest-environment jsdom
+
+// The chat pill in the board's top row only exists for the online screen —
+// a bot game has no chat, so GameBoard simply never receives the handler
+// that draws it. This pins that down as a prop contract rather than
+// something only the online route happens to exercise.
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mount, unmount, flushSync } from 'svelte';
+import { create } from '@bufbuild/protobuf';
+import GameBoard from '../src/lib/components/GameBoard.svelte';
+import { ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
+import { game } from '../src/lib/stores/game.svelte.js';
+import { Status, connection } from '../src/lib/ws/connection.svelte.js';
+
+function startGame() {
+ game.apply(
+ create(ServerMessageSchema, {
+ payload: {
+ case: 'gameStarted',
+ value: {
+ openingWord: 'bình yên',
+ currentSyllable: 'yên',
+ myTurn: true,
+ deadlineUnixMs: 1_700_000_020_000n,
+ turnSeq: 1,
+ turnLimitMs: 20_000,
+ players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
+ turnPlayerId: 'p1'
+ }
+ }
+ })
+ );
+}
+
+/** @param {Partial[0]['props']>} [extra] */
+function renderGameBoard(extra = {}) {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+ const component = mount(GameBoard, {
+ target,
+ props: {
+ onsubmit: () => true,
+ onresign: () => {},
+ onclaimdeadend: () => {},
+ onreportword: () => {},
+ ...extra
+ }
+ });
+ flushSync();
+ return { target, component };
+}
+
+beforeEach(() => {
+ game.leave();
+ connection.status = Status.OPEN;
+ // jsdom implements neither; ChainHistory (rendered inside the board) uses
+ // both to keep the newest word in view, which is not what this file
+ // tests.
+ Element.prototype.scrollTo = () => {};
+ globalThis.ResizeObserver ??= class {
+ observe() {}
+ disconnect() {}
+ };
+ startGame();
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ connection.status = Status.CLOSED;
+});
+
+describe('the chat pill', () => {
+ it('is absent when the caller passes no onchatopen, as a bot game does', () => {
+ const { target, component } = renderGameBoard();
+
+ expect(target.querySelector('[data-testid="chat-pill"]')).toBeNull();
+ unmount(component);
+ });
+
+ it('appears once a handler is passed, as the online room does', () => {
+ const { target, component } = renderGameBoard({ onchatopen: () => {} });
+
+ expect(target.querySelector('[data-testid="chat-pill"]')).not.toBeNull();
+ unmount(component);
+ });
+
+ it('calls the handler on click and shows the unread count', () => {
+ let opened = 0;
+ const { target, component } = renderGameBoard({
+ onchatopen: () => opened++,
+ chatUnread: 3
+ });
+
+ /** @type {HTMLButtonElement | null} */
+ const pill = target.querySelector('[data-testid="chat-pill"]');
+ expect(pill?.textContent).toContain('3');
+ pill?.click();
+
+ expect(opened).toBe(1);
+ unmount(component);
+ });
+});
diff --git a/web/tests/word-input.test.js b/web/tests/word-input.test.js
new file mode 100644
index 0000000..dedf2ff
--- /dev/null
+++ b/web/tests/word-input.test.js
@@ -0,0 +1,179 @@
+// @vitest-environment jsdom
+
+// The word field's three direct writes — the turn seed, the suggestion fill,
+// and the submit-clear — are the exceptions to an otherwise fully
+// uncontrolled field, so each is worth pinning down: the seed fires once per
+// turn and never during the player's own composition, the suggestion lands
+// on click, and the seed itself is gated on the connection (C6) as well as
+// the turn.
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mount, unmount, flushSync } from 'svelte';
+import { create } from '@bufbuild/protobuf';
+import WordInput from '../src/lib/components/WordInput.svelte';
+import { RejectReason, ServerMessageSchema } from '../src/lib/proto/noitu/v1/game_pb.js';
+import { game } from '../src/lib/stores/game.svelte.js';
+import { Status, connection } from '../src/lib/ws/connection.svelte.js';
+
+/** @param {{ turnSeq?: number, currentSyllable?: string }} [fields] */
+function startTurn({ turnSeq = 1, currentSyllable = 'an' } = {}) {
+ game.apply(
+ create(ServerMessageSchema, {
+ payload: {
+ case: 'gameStarted',
+ value: {
+ openingWord: 'bình yên',
+ currentSyllable,
+ myTurn: true,
+ deadlineUnixMs: 1_700_000_020_000n,
+ turnSeq,
+ turnLimitMs: 20_000,
+ players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }],
+ turnPlayerId: 'p1'
+ }
+ }
+ })
+ );
+}
+
+/** @param {string} suggestion */
+function rejectWith(suggestion) {
+ game.apply(
+ create(ServerMessageSchema, {
+ payload: {
+ case: 'moveRejected',
+ value: {
+ reason: RejectReason.NOT_IN_DICTIONARY,
+ word: 'binh yen',
+ turnSeq: 1,
+ suggestion
+ }
+ }
+ })
+ );
+}
+
+function renderWordInput() {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+ const component = mount(WordInput, {
+ target,
+ props: { onsubmit: () => true, onreportword: () => {} }
+ });
+ flushSync();
+ /** @type {HTMLInputElement} */
+ const input = target.querySelector('input');
+ return { target, component, input };
+}
+
+beforeEach(() => {
+ game.leave();
+ connection.status = Status.OPEN;
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ connection.status = Status.CLOSED;
+});
+
+describe('seeding the turn', () => {
+ it('fills the field with the current syllable once the turn starts', () => {
+ startTurn({ currentSyllable: 'an' });
+ const { input, component } = renderWordInput();
+
+ expect(input.value).toBe('an ');
+ unmount(component);
+ });
+
+ it('does not seed, or focus, while the connection is down', () => {
+ // C6: seeding during a reconnect wrote into a field the player could
+ // not submit from, and the first composition event then undid it.
+ connection.status = Status.CLOSED;
+ startTurn({ currentSyllable: 'an' });
+ const { input, component } = renderWordInput();
+
+ expect(input.value).toBe('');
+ expect(document.activeElement).not.toBe(input);
+ unmount(component);
+ });
+
+ it('does not reseed the same turn once the player has started typing', () => {
+ startTurn({ turnSeq: 5, currentSyllable: 'an' });
+ const { input, component } = renderWordInput();
+ expect(input.value).toBe('an ');
+
+ input.value = 'an ninh';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ flushSync();
+
+ // A connection blip and recovery re-evaluates the seeding effect
+ // (enabled depends on connection.status) without the turn changing.
+ connection.status = Status.CLOSED;
+ flushSync();
+ connection.status = Status.OPEN;
+ flushSync();
+
+ expect(input.value).toBe('an ninh');
+ unmount(component);
+ });
+
+ it('seeds again for a new turn', () => {
+ startTurn({ turnSeq: 1, currentSyllable: 'an' });
+ const { input, component } = renderWordInput();
+ input.value = 'an ninh';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+
+ startTurn({ turnSeq: 2, currentSyllable: 'ninh' });
+ flushSync();
+
+ expect(input.value).toBe('ninh ');
+ unmount(component);
+ });
+});
+
+describe('composing on the player\'s own turn', () => {
+ it('leaves an in-progress composition alone', () => {
+ startTurn({ currentSyllable: 'an' });
+ const { input, component } = renderWordInput();
+
+ input.dispatchEvent(new Event('compositionstart'));
+ input.value = 'an niệ';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ flushSync();
+
+ // undoInput only reverts out of turn; on the player's own turn it is
+ // a no-op, so a composed character in flight is never taken back.
+ expect(input.value).toBe('an niệ');
+ input.dispatchEvent(new Event('compositionend'));
+ unmount(component);
+ });
+});
+
+describe('a rejected word\'s suggestion', () => {
+ it('fills the field with the suggestion on click', () => {
+ startTurn({ currentSyllable: 'an' });
+ const { target, input, component } = renderWordInput();
+ input.value = 'binh yen';
+ rejectWith('bình yên');
+ flushSync();
+
+ /** @type {HTMLButtonElement | null} */
+ const suggestion = target.querySelector('.suggestion');
+ expect(suggestion).not.toBeNull();
+ suggestion?.click();
+ flushSync();
+
+ expect(input.value).toBe('bình yên');
+ unmount(component);
+ });
+
+ it('offers no suggestion button when the server sent none', () => {
+ startTurn({ currentSyllable: 'an' });
+ const { target, component } = renderWordInput();
+ rejectWith('');
+ flushSync();
+
+ expect(target.querySelector('.suggestion')).toBeNull();
+ unmount(component);
+ });
+});
From df2ad837705af9092026a5a0e52e3c0873ee3a3d Mon Sep 17 00:00:00 2001
From: tiennm99
Date: Mon, 21 Sep 2026 16:31:02 +0700
Subject: [PATCH 8/8] docs(reports): record the web review actions
implementation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also keep a copy of the architecture review this work implements, alongside
the report — it was untracked in the checkout this branch forked from.
---
...eveloper-260921-1535-web-review-actions.md | 167 ++++++++++++++++++
1 file changed, 167 insertions(+)
create mode 100644 plans/reports/fullstack-developer-260921-1535-web-review-actions.md
diff --git a/plans/reports/fullstack-developer-260921-1535-web-review-actions.md b/plans/reports/fullstack-developer-260921-1535-web-review-actions.md
new file mode 100644
index 0000000..d23f9ee
--- /dev/null
+++ b/plans/reports/fullstack-developer-260921-1535-web-review-actions.md
@@ -0,0 +1,167 @@
+# Web review actions — implementation report
+
+Branch `worktree-agent-ae280f2081bd718f6` (fast-forwarded from `main`/dd3b463 to `dev`@3ef9f48
+before starting — the worktree had been created one point behind `dev`, missing quick-match,
+dead-end claim, ESLint config and the rest the review was written against) · worktree
+`/workspace/tiennm99/noitu/.claude/worktrees/agent-ae280f2081bd718f6`.
+
+Seven commits on top of `dev`@3ef9f48, all `web/`, nothing pushed:
+
+```
+c4502f2 refactor(web): split the game store into shape, apply and store files
+70ae09d refactor(web): type the wire instead of passing any across it
+eace594 refactor(web): extract the online screen's request machine into a store
+f0c2334 test(web): cover the room-session request machine
+f0bcb60 refactor(web): extract ArmedButton for the three press-twice controls
+69310a3 test(web): mount ChatPanel under jsdom for fold and unread accounting
+cf48d27 test(web): mount WordInput and GameBoard under jsdom
+```
+
+## Verification
+
+| | Before | After |
+|---|---|---|
+| `npm run lint` | 0 errors, 33 warnings (32 `jsdoc/reject-any-type`, 1 `check-param-names`) | 0 errors, **1 warning** |
+| `npm run check` | 0 errors, 0 warnings (meaninglessly, per the review) | 0 errors, 0 warnings — now over a typed store |
+| `npm test` | 221 passed / 12 files | **258 passed / 16 files** |
+
+The one remaining warning: `tests/room-code.test.js:77` deliberately calls
+`normalizeRoomCode(/** @type {any} */ (undefined))` against its documented `string`-only
+signature, to prove the function's own defensive `String(raw ?? '')` — that is the point of the
+test, not an oversight.
+
+No Playwright run (no browser on this host, per workspace rules).
+
+## Per deliverable
+
+**1 — Resume latch time-box.** `stores/room-session.svelte.js` owns `resuming`; the page arms a
+5s (`RESUME_TIMEOUT_MS`) timer once `resuming && connection.status === Status.OPEN`, calling
+`session.noteResumeFailed(named)` if nothing has resolved it by then — same transition the
+existing "resume answered with an error" effect already ran, now shared by both paths. A server
+new enough to answer `session_not_resumable` clears it sooner through the ordinary error effect
+(the message already existed in `vi.js`). The join form's `resumeFailed` banner reuses that same
+string rather than inventing new copy. Unit-tested in `tests/room-session.test.js` (timeout,
+already-resolved-before-the-timer-fires, named vs. unnamed invite code held behind it).
+
+**2 — `leave()` calls `forgetSession()`.** One line in `routes/online/+page.svelte`'s `leave()`,
+matching the teardown path. No dedicated test file (it is a page-level wiring fact, not store
+logic) — covered implicitly by the held-action semantics test in point 6, and by inspection: `git
+show eace594 -- routes/online/+page.svelte` shows the added call.
+
+**3 — Type the store.** `initialState()` now returns `GameState` (real `@typedef` with one
+`@property` per field, folded into `game-shape.js`), not `any`. `apply()` switches on
+`payload.case` without destructuring, so the oneof narrows. `svelte-check` stayed at 0
+errors/warnings after the retype — no latent bugs surfaced in the 16 components, which the review
+flagged as a real possibility; I take that as the store's shape genuinely having matched its
+usage everywhere, not as the check being weak (it now has a real type to fail against, and
+deliberately-wrong scratch edits during development did produce the expected errors and warnings
+before being fixed).
+
+**4 — Extract `stores/room-session.svelte.js`.** Owns `pending`/`resuming`/`needName`/`stalled`/
+`resumeFailed`/`queuedForS`/`heldAction`; no DOM, no runes beyond `$state`, modelled on
+`bot-session.svelte.js`. The page keeps every timer (`setTimeout`/`setInterval`) and all layout —
+`JoinPanel`/`RoomLayout` extraction from the review's secondary suggestion was explicitly out of
+scope this round. `routes/online/+page.svelte` script is ~290 lines, down from ~400 in the
+pre-fast-forward review's line numbers (harder to compare directly since the branch had drifted,
+but the five request-machine `$effect`s the review named are gone from the page). Unit tests: join
+latch + refusal retry, resume latch + timeout + already-resolved race, quick-match queued→seated,
+held-action replace/retry/drop-on-teardown (18 tests, `tests/room-session.test.js`).
+
+**5 — Type the wire.** `ws/client.js`, `ws/connection.svelte.js`, `stores/bot-session.svelte.js`
+now use the generated `ServerMessage`/`ClientMessage` union instead of `any`; timers typed
+`ReturnType`. Cleared every `jsdoc/reject-any-type` in `src/`. In `tests/`, five
+files lost their casts for free once the store and wire were typed
+(`game-store.test.js` ×3, `game-wire.test.js` ×2 via a typed `decode()`, `ws-client.test.js` ×6).
+**Kept:** `tests/room-code.test.js:77` — see Verification above, the one deliberate `any`.
+
+**6 — Held requests for lobby actions.** `room-session`'s `heldAction` slot (replace-not-queue: a
+later action supersedes an earlier unset one, since e.g. readying then leaving before reconnect
+means leave should win) backs `cancelQueue`, `leave`, `ready`/`unready`, `start`, `kick`. Each
+tries `send()` first and only latches on refusal; a socket-open effect retries alongside the
+existing join/create flush. `Lobby.svelte`'s local one-shot `unsent` flag (which never noticed a
+background retry had succeeded) is now the `actionHeld` prop, driven straight off
+`session.state.heldAction`. Tested: 6 of the 18 `room-session.test.js` cases.
+
+**7 — Split `game.svelte.js`.** `stores/game-shape.js` (typedefs, `initialState()`, wire decoders
+`toSenses`/`toParts`/`toScore`/`toSlot`) / `stores/game-apply.js` (`applyTo(state, msg,
+{reset, leave})`, pure) / `stores/game.svelte.js` (`$state`, `reset`/`leave`, derived accessors,
+singleton). `apply()` wraps `applyTo()` in try/catch, logging and keeping the previous snapshot on
+a throw. All 61 pre-existing store tests pass unmodified against the new module split (they only
+import from `game.svelte.js`, which still re-exports `CHAT_WINDOW` and `createGameStore`).
+
+**8 — Component tests under jsdom (first half).** `tests/chat-panel.test.js` (9: fold-by-default,
+opens on toggle, never folds when not collapsible, unread counts only while folded, clears on
+open, recounts after a fold/reread cycle, send/clear, whitespace-only refused).
+`tests/word-input.test.js` (7: seeds once per turn, does not seed/focus while offline — the C6
+regression — does not reseed the same turn across a connection blip, reseeds on a genuine new
+turn, leaves an in-progress composition alone on the player's own turn, suggestion fills on click,
+no suggestion button when the server sent none). `tests/game-board.test.js` (3: chat pill absent
+with no `onchatopen`, present once one is passed, click reaches the handler and shows the unread
+count). Needed one infrastructure fix: `vite.config.js` now sets
+`resolve.conditions: ['browser']` gated on `process.env.VITEST`, or Svelte resolves its
+server-rendering entry point under Vitest and `mount()` throws
+`lifecycle_function_unavailable`; `vite dev`/`vite build` are unaffected since `VITEST` is only
+set by the Vitest CLI. jsdom also needed local polyfills for `Element.scrollTo` and
+`ResizeObserver` (used by `ChatPanel`/`ChainHistory` for auto-scroll and reflow, neither
+implemented by jsdom) — stubbed per test file, not globally.
+
+`e2e/helpers.js`/`e2e/pvp-game.spec.js` fixed per the flake analysis: `playingPair` now calls
+`joinRoomSeated` instead of `joinRoom`, and `readyAndStart` asserts
+`guest.getByTestId('my-ready')` reads "Đã sẵn sàng" (the actual `t.isReady` string — the review's
+own example text was illustrative, not the literal copy) before touching Start. **Unverified
+locally** — no browser on this host; these are read-through-verified against the actual
+`Lobby.svelte` markup and `vi.js` strings, not run.
+
+Playwright suite (47→~14) was **not** cut, per the task's explicit instruction to keep it as-is
+for now.
+
+**9 — `ArmedButton.svelte`.** One component for resign, claim-dead-end and kick: owns the arm
+timer and the disarm-on-`disabled` effect, adds `aria-pressed` (announces the armed state to
+assistive tech on the same control that already has focus, closing the a11y gap the review
+flagged — a screen reader speaks a control's name on focus, not on an in-place label mutation).
+Behaviour is otherwise identical, with one acknowledged, minor, unstated-invariant change: kick
+now arms per seat (each `ArmedButton` instance is independent) rather than sharing one
+Lobby-level "which seat is armed" slot, so two seats could in principle be armed at once within
+the 4s window. No test or review language documented the old cross-seat exclusivity as intended
+behaviour, and no e2e spec exercises it. GameBoard's and Lobby's `