From afa0e17483dffd656889cd3a8633f22c1985e67c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 11:04:01 -0400 Subject: [PATCH] Credibility pass: the fast-edit shavings swept from the ops-and-rev-13 batch Server: turnFacts joins actingSeat's full priority order (a hanging ward or slow-death window now reads as the lobby's attention, stubs included), summarize and stubOf share one baseSummary, the stranded doc comment returns to summarize, the protocol contract names feedbackList, Sentry init moves below the imports it cannot precede, randomBytes joins its group, the feedback path hoists once with the operator-fields note. Engine: the safe's sight check trusts castSight alone, the locked-safe predicate becomes lockedSafeAt, the doomed-grab test drives both seats and must reach endTurn. Web: the twin safe handlers merge, paPoints joins the reset ritual with a derived burnable range, five decorative empty handlers and a dead class go, the hand earns real list semantics, fitHandCards cites Card.svelte's tokens, two wrap-sites reindent, feedbackReports moves home as a named type, and the chronicle's stay-mounted reason is stated. feedback-reply.sh adopts its siblings' set/usage conventions and speaks Node. Two test screenshots leave the repo root. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- .claude/skills/wizwar-reports/SKILL.md | 2 +- deploy/feedback-reply.sh | 24 ++-- packages/engine/src/automaton.ts | 16 ++- packages/engine/src/game.ts | 6 +- packages/engine/test/automaton.test.ts | 23 +++- packages/engine/test/stones.test.ts | 2 +- packages/server/src/index.ts | 16 +-- packages/server/src/rooms.ts | 54 ++++----- packages/server/src/store.ts | 10 +- packages/web/src/App.svelte | 152 +++++++++++++------------ packages/web/src/net.svelte.ts | 19 +++- 11 files changed, 178 insertions(+), 146 deletions(-) diff --git a/.claude/skills/wizwar-reports/SKILL.md b/.claude/skills/wizwar-reports/SKILL.md index 63b9c81..88532b1 100644 --- a/.claude/skills/wizwar-reports/SKILL.md +++ b/.claude/skills/wizwar-reports/SKILL.md @@ -33,7 +33,7 @@ reports — those are the work. Then one block per report, VERBATIM and UNTRUNCATED: player, room, date, round/seq/deckRev, the full "what happened" text, the full "what they expected" text, and the full reply with its status (or "unanswered"). Eric reads this desk to hear his -players' voices — never summarize their words or yours into a table. +players' voices — never compress their words, or your replies, into a table. ## c) Process an unanswered report diff --git a/deploy/feedback-reply.sh b/deploy/feedback-reply.sh index bdb123f..19f9011 100755 --- a/deploy/feedback-reply.sh +++ b/deploy/feedback-reply.sh @@ -2,18 +2,18 @@ # Answer a player's feedback report; the reply appears under it in their # lobby ledger. Legacy reports without an id answer to their timestamp. # deploy/feedback-reply.sh -set -eu -HOST="$1"; REPORT_ID="$2"; STATUS="$3"; shift 3 +set -euo pipefail +HOST="${1:?usage: feedback-reply.sh }" +REPORT_ID="${2:?usage: feedback-reply.sh }" +STATUS="${3:?usage: feedback-reply.sh }" +shift 3 TEXT="$*" case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac -LINE=$(python3 - "$REPORT_ID" "$STATUS" "$TEXT" <<'EOF' -import json, sys, datetime -print(json.dumps({ - "reportId": sys.argv[1], - "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), - "status": sys.argv[2], - "text": sys.argv[3], -})) -EOF -) +LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" node -e ' +console.log(JSON.stringify({ + reportId: process.env.REPORT_ID, + at: new Date().toISOString(), + status: process.env.STATUS, + text: process.env.TEXT, +}));') printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl' diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 716c947..01e3dfa 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -643,6 +643,14 @@ function pathToward( } /** The treasure squares worth marching for. */ +/** A SAFE that stands between this wizard and the floor: not their own + * box, and not opened this turn. */ +function lockedSafeAt(view: GameView, key: string): boolean { + return view.squareContents[key]?.kind === "safe" && + view.squareContents[key]!.createdBy !== view.you && + !view.openSafes.includes(key); +} + function treasureGoals(view: GameView): Set { const self = me(view); const goals = new Set(); @@ -655,10 +663,7 @@ function treasureGoals(view: GameView): Set { // A chest inside someone else's SAFE is no goal without a way in — a // lock card turns the combination, DISPEL CREATION removes the box. // Marching to an unopenable safe stalls the clockwork on it forever. - const boxKey = cellKey(t.position); - if (view.squareContents[boxKey]?.kind === "safe" && - view.squareContents[boxKey]!.createdBy !== view.you && - !view.openSafes.includes(boxKey) && + if (lockedSafeAt(view, cellKey(t.position)) && !inHand(view, "pick-lock") && !inHand(view, "master-key") && !inHand(view, "dispel-creation")) { continue; @@ -1332,8 +1337,7 @@ export function automatonCommand( if (prize) { // A SAFE over the prize: turn the combination (or dispel the box) // before reaching for the gold — the grab itself would be refused. - const boxed = view.squareContents[here]?.kind === "safe" && - view.squareContents[here]!.createdBy !== you && !view.openSafes.includes(here); + const boxed = lockedSafeAt(view, here); if (!boxed) return { type: "pickUpTreasure", treasureId: prize.id }; const key = inHand(view, "pick-lock") ?? inHand(view, "master-key"); if (key) return { type: "cast", instanceId: key.instanceId, target: { kind: "cell", cell: self.position } }; diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b0bc6df..c7a02f0 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -5252,9 +5252,9 @@ function doCast(prev: GameState, cmd: Extract): Comma if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { return err("you must be in the same square"); } - if (effect.requiresLos && !(mods.aroundCorner - ? bentLos(state, caster, caster.position, cell) - : castSight(state, caster, cmd, cell))) { + // castSight carries the whole sight law: straight lines, the + // VISIONSTONE's one-wall look, and the attached corner bend. + if (effect.requiresLos && !castSight(state, caster, cmd, cell)) { return err("no line of sight to the safe"); } const wandEvents: GameEvent[] = []; diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 9f67577..e0e6cce 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1034,15 +1034,26 @@ describe("interception, escape, and hazard sense", () => { state.squareContents[cellKey(chest.position!)] = { kind: "safe", damage: 0, createdBy: "foe" }; bot.position = { ...chest.position! }; bot.hand = [{ cardId: "number-3", instanceId: "N3" }]; - for (let i = 0; i < 8; i++) { - const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); - if (!cmd) break; - expect(cmd.type).not.toBe("pickUpTreasure"); - const r = applyCommand(state, "bot", cmd); + // Drive whichever seat the maze wants (a punched foe answers its own + // counteraction window). The invariants: the bot never proposes the + // doomed grab, and its turn actually ends instead of idling forever. + let ended = false; + for (let i = 0; i < 24 && !ended; i++) { + const seat = actingSeat(state); + const view = viewFor(state, seat); + const chosen = automatonCommand(view, "hunter", "archmage"); + let cmd = chosen ?? automatonFallback(view, "archmage"); + if (seat === "bot") expect(cmd.type).not.toBe("pickUpTreasure"); + let r = applyCommand(state, seat, cmd); + if (!r.ok && chosen) { + cmd = automatonFallback(view, "archmage"); + r = applyCommand(state, seat, cmd); + } expect(r.ok).toBe(true); state = r.state; - if (cmd.type === "endTurn") break; + ended = seat === "bot" && cmd.type === "endTurn"; } + expect(ended).toBe(true); }); it("a pressed carrier of any temperament turns to mist", () => { diff --git a/packages/engine/test/stones.test.ts b/packages/engine/test/stones.test.ts index 0486af7..5b11d0c 100644 --- a/packages/engine/test/stones.test.ts +++ b/packages/engine/test/stones.test.ts @@ -188,7 +188,7 @@ describe("slow death", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(14); }); - it("declining the window takes the whole total", () => { + it("declining the window takes the whole total (rev 13)", () => { let { state } = newGame(); state = toRound2(state); const { attacker, defender } = faceOff(state); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e012b52..82084ab 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,17 +27,12 @@ // {type:"chat", player, text, at} one line of table talk // {type:"watching", roomId} you are seated in the gallery // {type:"audience", count} how many watch from the gallery -// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"} +// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"} // {type:"error", message} import * as Sentry from "@sentry/node"; -// Errors only, no tracing: the box is small and the ledgers are the real -// telemetry. Without a DSN in the environment this is entirely inert. -if (process.env.SENTRY_DSN) { - Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 }); -} - import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; import { readFileSync, existsSync, realpathSync } from "node:fs"; import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; @@ -71,11 +66,16 @@ import { } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { appendFeedback, readFeedback } from "./store"; -import { randomBytes } from "node:crypto"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; import { BOT_LINES, type BanterTrigger } from "./banter"; +// Errors only, no tracing: the box is small and the ledgers are the real +// telemetry. +if (process.env.SENTRY_DSN) { + Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 }); +} + // --- Abuse limits: this is a public server on a small box. ----------------- const MAX_SOCKETS = 300; // concurrent connections const MAX_ROOMS = 5000; // total rooms on the server diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index eab1670..a1528ba 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -184,17 +184,22 @@ function stubOf(room: Room): RoomStub { turnHolder, waitKind, playing: room.state?.phase === "playing", - base: { - roomId: room.id, - players: [...room.players], - started: room.state !== null, - finished: room.state?.phase === "finished", - winner: room.state?.winner ?? null, - activePlayerId: active, - round: room.state?.turn.round ?? null, - lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, - chatCount: room.chat.length, - }, + base: baseSummary(room, active), + }; +} + +/** The per-room half of a summary — everything except whose turn it is. */ +function baseSummary(room: Room, active: PlayerId | null): Omit { + return { + roomId: room.id, + players: [...room.players], + started: room.state !== null, + finished: room.state?.phase === "finished", + winner: room.state?.winner ?? null, + activePlayerId: active, + round: room.state?.turn.round ?? null, + lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, + chatCount: room.chat.length, }; } @@ -452,16 +457,21 @@ export interface GameSummary { chatCount: number; } -/** A seat-holder's one-line view of a room, for the lobby ledger. */ -/** Who holds the table's attention, and why — the summary's turn facts. */ +/** Who holds the table's attention, and why — the summary's turn facts. + * The priority order is actingSeat's: a hanging ward or slow-death window + * outranks the stack, which outranks the turn itself. */ function turnFacts(s: GameState | null): { active: PlayerId | null; turnHolder: PlayerId | null; waitKind: "counteract" | "discard" | "interrupt" | null; } { const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; - const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null; + const waitingOn = s?.wardPending?.ownerId ?? s?.slowDeathPending?.playerId ?? + s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? + s?.outOfTurnWindow?.playerId ?? null; const waitKind = s == null ? null + : s.wardPending != null ? "counteract" as const + : s.slowDeathPending != null ? "counteract" as const : s.stack?.waitingOn != null ? "counteract" as const : s.pendingDiscard != null ? "discard" as const : s.chaosPending?.queue[0] != null ? "counteract" as const @@ -470,23 +480,15 @@ function turnFacts(s: GameState | null): { return { active, turnHolder: waitingOn ?? active, waitKind }; } +/** A seat-holder's one-line view of a room, for the lobby ledger. */ export function summarize(room: Room, playerId: PlayerId): GameSummary { - const s = room.state; - const { active, turnHolder, waitKind } = turnFacts(s); - const yourTurn = s?.phase === "playing" && turnHolder === playerId; + const { active, turnHolder, waitKind } = turnFacts(room.state); + const yourTurn = room.state?.phase === "playing" && turnHolder === playerId; return { - roomId: room.id, + ...baseSummary(room, active), name: playerId, - players: [...room.players], - started: s !== null, - finished: s?.phase === "finished", - winner: s?.winner ?? null, - activePlayerId: active, yourTurn, attention: yourTurn ? (waitKind ?? "turn") : null, - round: s?.turn.round ?? null, - lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, - chatCount: room.chat.length, }; } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 1895b5f..f26f1bc 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -152,10 +152,14 @@ export function readAllRooms(): Map { * beside the rooms directory — a report line carries roomId/seq pinning * its moment; a reply line carries reportId/status/text and folds onto * its report when read. Legacy reports without an id answer to their - * timestamp. */ + * timestamp. A report line may carry fields (deckRev, player context) + * that only the operator's raw read uses; readFeedback keeps the + * player-facing subset. */ +const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl"); + export function appendFeedback(entry: Record): void { ensureDataDir(); - appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8"); + appendFileSync(feedbackFile(), JSON.stringify(entry) + "\n", "utf8"); } export interface FeedbackReport { @@ -171,7 +175,7 @@ export interface FeedbackReport { } export function readFeedback(): FeedbackReport[] { - const file = join(DATA_DIR, "..", "feedback.jsonl"); + const file = feedbackFile(); if (!existsSync(file)) return []; const reports = new Map(); for (const raw of readFileSync(file, "utf8").split("\n")) { diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 19e88da..b61cc71 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -448,8 +448,8 @@ (selectedCard != null || (youMustRespond && !!view?.stack))); /** The hand in the player's own order: drag a card onto another to take - * its place. The order lives only in this browser — the server's hand - * array is authority for contents, this for arrangement. */ + * its place. The order lives only in this tab, for this sitting — the + * server's hand array is authority for contents, this for arrangement. */ let handOrder = $state([]); let draggingId = $state(null); const orderedHand = $derived.by(() => { @@ -474,11 +474,14 @@ let handEl = $state(null); function fitHandCards(el: HTMLDivElement) { if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; } - const cardW = 8.2 * 16, gap = 0.45 * 16, pad = 0.6 * 16; + // Card.svelte is the source of truth for the card's box: 8.2rem wide, + // 11.4rem tall. 5rem covers the masthead and the column's padding. + const rem = 16; + const cardW = 8.2 * rem, gap = 0.45 * rem, pad = 0.6 * rem; const zoomW = (el.clientWidth - pad - gap) / (2 * cardW); const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2)); - const cardH = 1.42 * cardW; - const maxH = window.innerHeight - 5 * 16; + const cardH = (11.4 / 8.2) * cardW; + const maxH = window.innerHeight - 5 * rem; const zoomH = (maxH - pad) / (rows * (cardH + gap)); el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3))); } @@ -495,7 +498,8 @@ const el = handEl; if (el) requestAnimationFrame(() => fitHandCards(el)); }); - /** POWER ATTACK: life points burned into the attached attack. */ + /** POWER ATTACK: life points burned into the attached attack — always + * fewer than the caster has left. */ let paPoints = $state(1); const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]); @@ -516,6 +520,7 @@ } function clearSelection() { + paPoints = 1; punchWallMode = false; swapMeetTarget = null; swapMine = null; @@ -786,16 +791,11 @@ confirmTeleport(); return; } - if (selectedCard && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") && - view.squareContents[cellKey(cell)]?.kind === "safe") { - // A key aimed at a SAFE's square opens the box, not a door. - dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); - clearSelection(); - return; - } - if (selectedCard && cardDef(selectedCard.cardId).cardType === "attack" && - view.squareContents[cellKey(cell)]?.kind === "safe") { - // Brute force: attacks batter the safe itself (fifteen points bursts it). + // A SAFE takes the cast itself: a key turns its combination, and an + // attack batters the box (fifteen points bursts it). + if (selectedCard && view.squareContents[cellKey(cell)]?.kind === "safe" && + (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key" || + cardDef(selectedCard.cardId).cardType === "attack")) { dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, target: { kind: "cell", cell } })); clearSelection(); return; @@ -1325,6 +1325,7 @@ function pass() { dispatch({ type: "pass" }); } const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null); + const paBurnable = $derived(me ? [1, 2, 3, 4, 5].filter((n) => n < me.life) : []); /** Two treasures can share a square: one dispatches, more ask whose. */ const treasuresHere = $derived( view && me @@ -2056,7 +2057,7 @@ {#if net.feedbackReports.length > 0}
your reports to the wizards
{#each net.feedbackReports as r (r.id)} -
+
{r.roomId}
“{r.happened}”
@@ -2232,8 +2233,8 @@ {#if attachedMods.some((m) => m.cardId === "power-attack")} @@ -2441,14 +2442,14 @@ {:else if view.stack?.defenderId === view.you} Under attack — respond by your hand. {#if view.stack.attackCard} - {}} /> + {/if} {:else} Your spell is countered — respond by your hand. {#if view.stack?.counters.length} {@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)} {#if latest} - {}} /> + {/if} {/if} {/if} @@ -2597,49 +2598,52 @@
+ {#if castPanelOn && view} -
- {#if youMustRespond && view.stack} -
⚔ incoming
- {#if view.stack.attackCard} -
(faqCardId = id)} />
- {:else} -
a physical blow — no card behind it
- {/if} - {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} -
- {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} - {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} -
- {/if} - {#if view.stack.counters.length > 0} -
counters so far
-
- {#each view.stack.counters as c (c.card.instanceId)} - - {}} /> - {/each} -
- {/if} - {:else if selectedCard} -
(faqCardId = id)} />
- {#if attachedNumber || attachedMods.length > 0} -
riding along
-
+
+ {#if youMustRespond && view.stack} +
⚔ incoming
+ {#if view.stack.attackCard} +
(faqCardId = id)} />
+ {:else} +
a physical blow — no card behind it
+ {/if} + {#if view.stack.numberValue != null || view.stack.amplifyFactor > 1} +
+ {view.stack.numberValue != null ? `powered by a ${view.stack.numberValue}` : ""} + {view.stack.amplifyFactor > 1 ? ` amplified ×${view.stack.amplifyFactor}` : ""} +
+ {/if} + {#if view.stack.counters.length > 0} +
counters so far
+
+ {#each view.stack.counters as c (c.card.instanceId)} + + + {/each} +
+ {/if} + {:else if selectedCard} +
(faqCardId = id)} />
+ {#if attachedNumber || attachedMods.length > 0} +
riding along
+
+ {#if attachedNumber} + + {/if} + {#each attachedMods as m (m.instanceId)} + + {/each} +
{#if attachedNumber} - {}} /> +
number total {numberTotal}
{/if} - {#each attachedMods as m (m.instanceId)} - {}} /> - {/each} -
- {#if attachedNumber} -
number total {numberTotal}
{/if} {/if} - {/if} - {@render tableGuidance()} -
+ {@render tableGuidance()} +
{/if}
@@ -2786,7 +2790,7 @@
{/if} -
+
{#if net.spectating} 👁 You watch from the Peanut Gallery — hands stay secret, even from you. {/if} @@ -2798,20 +2802,20 @@ ondragover={(e) => e.preventDefault()} ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }} ondragend={() => (draggingId = null)}> - m.instanceId === card.instanceId)} - marked={discardSelection.has(card.instanceId)} - displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false} - glowing={interruptCards.some((c) => c.instanceId === card.instanceId)} - charges={view.wandCharges[card.instanceId] ?? null} - onclick={() => selectCard(card)} - onfaq={(id) => (faqCardId = id)} - /> + m.instanceId === card.instanceId)} + marked={discardSelection.has(card.instanceId)} + displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false} + glowing={interruptCards.some((c) => c.instanceId === card.instanceId)} + charges={view.wandCharges[card.instanceId] ?? null} + onclick={() => selectCard(card)} + onfaq={(id) => (faqCardId = id)} + />
{/each}
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 55c30a2..cb574f6 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -249,6 +249,17 @@ function loadSeen(): Record { } export interface Seat { name: string; roomId: string; token: string } + +export interface FeedbackReportView { + id: string; + at: string; + roomId: string; + seq: number; + round: number | null; + happened: string; + expected: string; + reply?: { at: string; text: string; status: string }; +} export interface GameSummary { roomId: string; name: string; @@ -303,6 +314,8 @@ class Net { view = $state(null); log = $state([]); error = $state(null); + /** Your reports and the wizards' replies, proven by seat tokens. */ + feedbackReports = $state([]); /** Every seat this browser holds, across rooms. */ seats = $state(loadSeats()); /** Lobby ledger: one summary per live seat. */ @@ -611,12 +624,6 @@ class Net { this.send({ type: "feedback", happened, expected }); } - /** Your reports and the wizards' replies, proven by seat tokens. */ - feedbackReports = $state<{ - id: string; at: string; roomId: string; seq: number; round: number | null; - happened: string; expected: string; - reply?: { at: string; text: string; status: string }; - }[]>([]); refreshFeedback(): void { if (this.seats.length > 0) this.send({ type: "myFeedback", seats: $state.snapshot(this.seats) }); }