mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
Merge branch 'worktree-agent-ae280f2081bd718f6' into dev
This commit is contained in:
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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user