Merge branch 'worktree-agent-ae280f2081bd718f6' into dev

This commit is contained in:
tiennm99 committed 2026-09-21 16:32:13 +07:00
commit 8c5bb8bef4
25 files changed
+1970 -742

No files matched your search

@@ -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<typeof setTimeout>`. 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 `<style>` blocks needed
`:global(.resign)`/`:global(.claim-dead-end)`/`:global(.kick...)` — those buttons are now rendered
by a child component, so the parent's scoped-style attribute no longer reaches them; the selectors
and rules themselves are untouched.
**10 — Meanings key.** `ChainHistory.svelte`'s inner `{#each entry.meanings as sense (sense.gloss)}`
→ keyed by index (`senseIndex`), with a comment stating why (list is neither reordered nor
filtered, so an index is stable; the dictionary gives no gloss-uniqueness guarantee).
**11 — Smaller findings.** Lobby chat now unfolds on the `over` phase transition (phase never
actually revisits `'lobby'` after the first game — it goes `over` → next `gameStarted` →
`'playing'` directly, so "fold once, stay folded forever" was the only reachable outcome without
this). `WordInput`'s turn-seed effect now gates on the same `enabled` derived value the submit
path already uses (folds in the connection check), fixing C6 and simplifying the guard from three
conditions restated to one shared one.
## Deferred / explicitly out of scope
- `JoinPanel.svelte` / `RoomLayout.svelte` extraction (review §1.1 secondary suggestion) — task
said the page keeps layout.
- Playwright 47→~14 trim (review §6) — task said not yet.
- P2 (route-swap socket race), P3 (resume-failure swallows unrelated errors), and the rest of
review §5's accessibility gaps beyond #9 — not in the 11-item scope given.
- The `?? []` guards on wire-decoded repeated fields (review notes these are dead under
protobuf-es v2) — left as defensive; not an action item.
## Unresolved questions
1. Kick's per-seat independent arming (see #9) is a real, if narrow, behaviour change from a
Lobby-level shared arm slot. Flagging rather than deciding — no stated invariant either way,
and reverting to shared-slot semantics inside `ArmedButton` would mean lifting arm state back
out to the parent, undoing the DRY the component exists for.
2. The e2e helper fixes (readyAndStart/playingPair) are read-through-verified only; this
environment cannot run Playwright to confirm the flake is actually gone.
Status: DONE
Summary: All 11 deliverables landed on `worktree-agent-ae280f2081bd718f6` at
`/workspace/tiennm99/noitu/.claude/worktrees/agent-ae280f2081bd718f6`; lint 0 errors/1 deliberate
warning (was 33), check 0 errors/0 warnings, 258/258 tests green (was 221), 7 focused commits, no
push, no server/proto/generated-client edits.
Concerns: kick's arming is now per-seat rather than shared (see unresolved #1); e2e helper edits
are unverified without a browser (see unresolved #2).
+8 -1
View File
@@ -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();
+3 -1
View File
@@ -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,
+2 -2
View File
@@ -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;
+84
View File
@@ -0,0 +1,84 @@
<script>
/**
* A press-twice control, in place of a native confirm(): resigning,
* claiming a dead end and kicking a player all need "are you sure"
* without one, because confirm() blocks the main thread and the
* countdown ring's frame loop keeps running underneath it — hesitating
* over the dialog can cost the very turn the confirmation was meant to
* protect.
*
* The second press is the same control asking again, not a different
* one, so this owns the arm timer and disarms itself the moment
* `disabled` goes true — an armed button that loses the offer it was
* making (the turn moves on, the seat becomes unkickable) must not sit
* there waiting for a press that would now mean something else.
*
* `aria-pressed` carries the armed state to assistive tech. A screen
* reader announces a control's accessible name on focus, not on an
* in-place mutation of it, so a swapped label alone is silent to a
* non-sighted player on the first press; the pressed-state change on the
* element they already have focus on is what gets spoken.
* @type {{
* label: string,
* confirmLabel: string,
* onconfirm: () => void,
* disabled?: boolean,
* armMs?: number,
* class?: string,
* testid?: string,
* children?: import('svelte').Snippet
* }}
*/
let {
label,
confirmLabel,
onconfirm,
disabled = false,
armMs = 4000,
class: className = '',
testid,
children
} = $props();
let armed = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let timer;
$effect(() => {
if (disabled) {
clearTimeout(timer);
armed = false;
}
});
$effect(() => () => clearTimeout(timer));
function press() {
if (armed) {
clearTimeout(timer);
armed = false;
onconfirm();
return;
}
armed = true;
clearTimeout(timer);
timer = setTimeout(() => (armed = false), armMs);
}
</script>
<button
type="button"
class={className}
class:arming={armed}
{disabled}
aria-pressed={armed}
aria-label={armed ? confirmLabel : label}
data-testid={testid}
onclick={press}
>
{#if children}
{@render children()}
{:else}
{armed ? confirmLabel : label}
{/if}
</button>
+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>
+23 -85
View File
@@ -1,4 +1,5 @@
<script>
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ChainHistory from '$lib/components/ChainHistory.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import CountdownRing from '$lib/components/CountdownRing.svelte';
@@ -42,17 +43,6 @@
onchatopen
} = $props();
/** How long an armed resign or claim button waits before it goes back to being safe. */
const ARM_MS = 4000;
let arming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let armTimer;
let claimArming = $state(false);
/** @type {ReturnType<typeof setTimeout>} */
let claimArmTimer;
// Whose turn it is, said by name. With four people at the table "the
// opponent is thinking" stops naming anybody.
const turnLabel = $derived.by(() => {
@@ -68,61 +58,9 @@
// is to leave the room, which the lobby's own button does.
const canResign = $derived(game.state.myTurn && !offline);
// An armed button that loses the turn goes back to being safe: the second
// press would arrive at a button that is no longer the one the player was
// looking at.
$effect(() => {
if (canResign) return;
clearTimeout(armTimer);
arming = false;
});
// A claim is the same offer resign is: made only on the player's own turn,
// on the same reasoning canResign already states.
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
$effect(() => {
if (canClaimDeadEnd) return;
clearTimeout(claimArmTimer);
claimArming = false;
});
/**
* Two presses, in place of a native confirm().
*
* confirm() blocks the main thread, which stops the countdown's animation
* frame loop while the server's deadline keeps running: hesitating over the
* dialog can cost the turn it was protecting. This keeps the board on screen
* and the clock moving, and disarms itself so a stray tap does not lie in
* wait.
*/
function armOrResign() {
if (arming) {
clearTimeout(armTimer);
arming = false;
onresign();
return;
}
arming = true;
clearTimeout(armTimer);
armTimer = setTimeout(() => (arming = false), ARM_MS);
}
/** Same two-press shape as armOrResign, and for the same reason. */
function armOrClaim() {
if (claimArming) {
clearTimeout(claimArmTimer);
claimArming = false;
onclaimdeadend();
return;
}
claimArming = true;
clearTimeout(claimArmTimer);
claimArmTimer = setTimeout(() => (claimArming = false), ARM_MS);
}
$effect(() => () => clearTimeout(armTimer));
$effect(() => () => clearTimeout(claimArmTimer));
</script>
<section class="board" data-phase={game.state.phase}>
@@ -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. -->
<button
type="button"
<ArmedButton
class="claim-dead-end"
class:arming={claimArming}
label={t.claimDeadEnd}
confirmLabel={t.claimDeadEndSure}
disabled={!canClaimDeadEnd}
onclick={armOrClaim}
>
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
</button>
onconfirm={onclaimdeadend}
/>
{/if}
{#if game.state.claimError}
<p class="claim-error" role="alert">
@@ -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}
<button
type="button"
<ArmedButton
class="resign"
class:arming
label={t.resign}
confirmLabel={t.resignSure}
disabled={!canResign}
onclick={armOrResign}
>
{arming ? t.resignSure : t.resign}
</button>
onconfirm={onresign}
/>
{/if}
<ChainHistory />
@@ -418,10 +352,14 @@
text-align: center;
}
/* :global(): these are ArmedButton's own <button>, 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;
+24 -48
View File
@@ -1,4 +1,5 @@
<script>
import ArmedButton from '$lib/components/ArmedButton.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
@@ -17,20 +18,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();
/** How long an armed kick waits before it goes back to being safe. */
const ARM_MS = 4000;
let { compact = false, actionHeld = false, onready, onstart, onkick, onleave } = $props();
const s = $derived(game.state);
// The seats nobody is in yet, drawn so a room that is waiting on people
@@ -51,34 +53,6 @@
})
);
/** The seat whose kick button is armed, if any. */
let armedKick = $state('');
/** @type {ReturnType<typeof setTimeout>} */
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));
return;
}
armedKick = playerId;
clearTimeout(armTimer);
armTimer = setTimeout(() => (armedKick = ''), ARM_MS);
}
$effect(() => () => clearTimeout(armTimer));
</script>
<section class="lobby" class:compact aria-label={t.lobbyTitle}>
@@ -140,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}
<button
type="button"
<ArmedButton
class="kick"
class:arming={armedKick === player.playerId}
label={t.kickPlayer}
confirmLabel={t.kickSure}
disabled={player.ready}
aria-label={armedKick === player.playerId ? t.kickSure : t.kickPlayer}
data-testid={`kick-${player.playerId}`}
onclick={() => armOrKick(player.playerId)}
testid={`kick-${player.playerId}`}
onconfirm={() => onkick(player.playerId)}
>
×
</button>
</ArmedButton>
{/if}
</span>
</li>
@@ -201,7 +174,7 @@
aria-label={t.dismiss}>×</button
>
</p>
{:else if unsent}
{:else if actionHeld}
<p class="error" role="alert" data-testid="lobby-unsent">{t.reconnecting}</p>
{/if}
@@ -212,7 +185,7 @@
class="primary"
disabled={!s.canStart || offline}
data-testid="start-game"
onclick={() => report(onstart())}
onclick={() => onstart()}
>
{t.startGame}
</button>
@@ -223,7 +196,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}
</button>
@@ -359,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 <button>, which this component's
* scoped-style attribute never reaches.
*/
.kick {
:global(.kick) {
position: relative;
width: 36px;
height: 36px;
@@ -374,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);
+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;
+3 -3
View File
@@ -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. */
+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.
*/
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
* @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
*/
/** @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'}
* Fields a game ending, or a new one starting, does not change: they
* describe the room, not the game.
* @type {readonly (keyof GameState)[]}
*/
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: '',
const KEPT = [
'nickname',
'roomCode',
'roomPlayers',
'canStart',
'maxPlayers',
'minPlayers',
'graceMs',
'chat',
'chatCount'
];
/**
* 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.
* 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
*/
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
};
}
/**
* Reads a word's senses off the wire.
* @param {any[] | undefined} senses
* @returns {Sense[]}
*/
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);
}
+192
View File
@@ -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;
}
};
}
+11 -6
View File
@@ -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<typeof setTimeout>) => 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<typeof setTimeout> | null} */
let reconnectTimer = null;
/** @type {any} */
/** @type {ReturnType<typeof setTimeout> | 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));
+3 -1
View File
@@ -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) {
+143 -89
View File
@@ -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. -->
<GameOverPanel isRecord={false} onhome={goHome} />
<Lobby compact onready={ready} onstart={start} onkick={kick} onleave={leave} />
<Lobby compact actionHeld={!!session.state.heldAction} onready={ready} onstart={start} onkick={kick} onleave={leave} />
{/snippet}
</GameBoard>
{:else}
<Lobby onready={ready} onstart={start} onkick={kick} onleave={leave} />
<Lobby actionHeld={!!session.state.heldAction} onready={ready} onstart={start} onkick={kick} onleave={leave} />
{/if}
</div>
@@ -475,7 +514,7 @@
<NicknameInput />
{#if needName}
{#if session.state.needName}
<p class="notice" role="alert" data-testid="name-needed">{t.nicknameNeeded}</p>
{/if}
@@ -483,7 +522,17 @@
<p class="error" role="alert" data-testid="join-error">{game.state.error}</p>
{/if}
{#if stalled}
{#if session.state.resumeFailed}
<!-- Not an error() from the store: a resume outcome is never a
ServerMessage the page decides how to react to in the ordinary
way, and the message is the same whether the server actually said
`session_not_resumable` or simply never answered. -->
<p class="notice" role="alert" data-testid="resume-failed">
{errorMessage('session_not_resumable')}
</p>
{/if}
{#if session.state.stalled}
<p class="error" role="alert" data-testid="connect-stalled">{t.connectStalled}</p>
{/if}
@@ -492,9 +541,9 @@
branch the moment a match is found — so the only button here is
the way out. -->
<div class="waiting" role="status">
<p>{fill(t.quickMatchWaiting, { n: queuedForS })}</p>
<p>{fill(t.quickMatchWaiting, { n: session.state.queuedForS })}</p>
<button type="button" onclick={cancelQueue}>{t.quickMatchCancel}</button>
{#if queuedForS >= QUICK_MATCH_NUDGE_S}
{#if session.state.queuedForS >= QUICK_MATCH_NUDGE_S}
<p class="hint">
{t.quickMatchNudge}
<a href="/play">{t.quickMatchNudgeLink}</a>
@@ -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. -->
<button type="button" class="primary" disabled={!!pending} onclick={playQuickMatch}>
{pending?.kind === 'quickMatch' ? t.connecting : t.quickMatch}
<button
type="button"
class="primary"
disabled={!!session.state.pending}
onclick={playQuickMatch}
>
{session.state.pending?.kind === 'quickMatch' ? t.connecting : t.quickMatch}
</button>
<button type="button" class="primary" disabled={!!pending} onclick={create}>
{pending?.kind === 'create' ? t.connecting : t.createRoom}
<button type="button" class="primary" disabled={!!session.state.pending} onclick={create}>
{session.state.pending?.kind === 'create' ? t.connecting : t.createRoom}
</button>
<form
@@ -535,8 +589,8 @@
bind:value={codeInput}
oninput={() => (codeError = '')}
/>
<button type="submit" disabled={!!pending}>
{pending?.kind === 'join' ? t.connecting : t.joinRoom}
<button type="submit" disabled={!!session.state.pending}>
{session.state.pending?.kind === 'join' ? t.connecting : t.joinRoom}
</button>
</div>
<p class="hint" class:invalid={codeError}>{codeError || t.roomCodeHint}</p>
+170
View File
@@ -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<typeof ChatPanel>[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([]);
});
});
+103
View File
@@ -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<ConstructorParameters<typeof GameBoard>[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);
});
});
+3 -3
View File
@@ -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', () => {
+8 -3
View File
@@ -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);
});
+265
View File
@@ -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([]);
});
});
+179
View File
@@ -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);
});
});
+6 -6
View File
@@ -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<typeof setup>} 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<typeof setup>} 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<typeof setup>} h
* @param {number} ticks
* @param {number} start
*/
+6
View File
@@ -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