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.
This commit is contained in:
tiennm99 committed 2026-09-21 16:03:03 +07:00
1 parent 3ef9f48df0
commit c4502f2793
6 files changed
+1072 -494

No files matched your search

@@ -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 `<ul class="seats">` 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<typeof setTimeout>`, 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` `<h1>` 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 `<ol aria-live="polite" aria-relevant="additions">`)
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 `<p id="word-rejection">` 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.
+8 -2
View File
@@ -112,7 +112,9 @@
travel with the total. -->
<p class="parts">
{entry.parts
.map((/** @type {any} */ p) => `+${p.value} ${pointKindLabels[p.kind] ?? ''}`)
.map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) =>
`+${p.value} ${pointKindLabels[p.kind] ?? ''}`
)
.join(' · ')}
</p>
{/if}
@@ -121,7 +123,11 @@
stripped the wiki markup and nothing here re-interprets it. -->
{#if entry.meanings.length}
<ol class="meanings" id={panelId}>
{#each entry.meanings as sense (sense.gloss)}
<!-- Keyed by position, not by gloss: the dictionary gives no
guarantee that two senses of one word have different
glosses, and a duplicate key throws at runtime. The list
is neither reordered nor filtered, so an index is stable. -->
{#each entry.meanings as sense, senseIndex (senseIndex)}
<li>{sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}</li>
{/each}
</ol>
+6 -2
View File
@@ -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;
+268
View File
@@ -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;
}
}
+227
View File
@@ -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
};
}
+58 -490
View File
@@ -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);
}