From 7a32370ccbff7efdb0adb97cab20e59f2dd71153 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 23 Aug 2026 21:41:11 -0400 Subject: [PATCH] Credibility pass: the reel's scars sanded, the veil finally hung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 --- .claude/skills/wizwar-audits/SKILL.md | 6 +-- packages/engine/src/automaton.ts | 3 -- packages/engine/test/automaton.test.ts | 3 +- packages/engine/test/expansion-combat.test.ts | 1 - packages/engine/test/ui-coverage.test.ts | 4 +- packages/server/src/index.ts | 12 ++--- packages/server/src/rooms.ts | 4 +- packages/web/src/App.svelte | 8 +-- packages/web/src/Replay.svelte | 7 +-- packages/web/src/TokenGallery.svelte | 3 +- packages/web/src/fpv/FirstPerson.svelte | 45 +++++++++------- packages/web/src/fpv/FpvWorkshop.svelte | 3 +- packages/web/src/fpv/raycast.ts | 53 +++++++++++-------- packages/web/src/fpv/textures.ts | 8 ++- packages/web/src/net.svelte.ts | 28 ++++++---- research/fx3d-sprite-spec.md | 28 +++++----- 16 files changed, 111 insertions(+), 105 deletions(-) diff --git a/.claude/skills/wizwar-audits/SKILL.md b/.claude/skills/wizwar-audits/SKILL.md index 60a999c..680b868 100644 --- a/.claude/skills/wizwar-audits/SKILL.md +++ b/.claude/skills/wizwar-audits/SKILL.md @@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` + any seq to inspect full state. To ask why a bot did something, rebuild the state at its turn and call `automatonCommand(viewFor(state, id), style, tier)` — and if its choice differs from the ledger, the engine -refused it and the fallback burned the turn (the X2XN pattern). +refused it and the fallback burned the turn. For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay each, and report phase / round / humans vs bots / last command's `at`. @@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`. Strict-replays every production ledger against the local engine; one refused command fails. A room whose ledger no longer replays becomes unreachable after restart. Rules changes while games are live need a -`deckRev` bump plus an engine gate (the convention survives the 2026-08 -reset to rev 1). +`deckRev` bump plus an engine gate. Production deckRev is currently 1, +so low numbers are not stale. diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index d6271ea..5dc5de8 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1124,7 +1124,6 @@ export function automatonCommand( const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you); const thief = thiefOfMine(view); if (!cursedIdiot) { - // Deliver or grab treasure underfoot. if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (!self.carriedTreasureId) { @@ -1187,8 +1186,6 @@ export function automatonCommand( if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) && view.turn.round > 1) { const sighted = sightedCellsFor(view); - // A wizard under the clockwork's own BUDDY pact is off the target list: - // attacking them would tear up the pact it just paid a card for. // Whoever cast BUDDY on you is safe from your first strike — the // engine refuses the attack outright. Your OWN pacts are merely // precious: striking someone you pacted tears up your protection. diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 1debece..938381b 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -348,9 +348,8 @@ describe("the clockwork respects the bush's shelter", () => { it("never attacks out of its own bush", () => { const { state, bot, foe } = faceOffWithFireball(); - foe.position = { x: bot.position.x, y: bot.position.y }; - state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" }; foe.position = { ...bot.position }; + state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" }; const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage") ?? automatonFallback(viewFor(state, "bot"), "archmage"); expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" }); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index f498815..1398422 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => { // One attack per turn: a second wrench is refused. expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false); } - expect(torn + kept).toBe(12); expect(torn).toBeGreaterThan(0); expect(kept).toBeGreaterThan(0); }); diff --git a/packages/engine/test/ui-coverage.test.ts b/packages/engine/test/ui-coverage.test.ts index 219f7d2..988d9ef 100644 --- a/packages/engine/test/ui-coverage.test.ts +++ b/packages/engine/test/ui-coverage.test.ts @@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => { if (!id) continue; // Only demands stated as refusals bind: `target.kind !== "cell"` etc. // (an optional `target?.kind === ...` branch is not a requirement). - if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) && + if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) && !cellCards.has(id)) missing.push(`${id} (cell)`); - if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) && + if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) && !edgeCards.has(id)) missing.push(`${id} (edge)`); } expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4910b8f..784bae5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -52,6 +52,7 @@ import { runCommand, seatTokenValid, SPECTATOR, + type CatchUpStep, startGame, summarize, viewForPlayer, @@ -105,7 +106,7 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) // Every lookup is a full-game replay, so results rest briefly in memory. interface ShareData { - steps: { actor: PlayerId; events: unknown[]; view: unknown }[]; + steps: CatchUpStep[]; actor: string; round: number; /** A whole finished game rather than one turn. */ @@ -127,12 +128,12 @@ function shareData(id: string): ShareData | null { for (const st of steps) { for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player; } - data = { steps: steps as unknown as ShareData["steps"], actor: winner, round: 0, whole: true }; + data = { steps, actor: winner, round: 0, whole: true }; } } else { const reel = momentSteps(room, SPECTATOR, share.turn); if (!("error" in reel) && reel.steps.length > 0) { - data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round }; + data = { steps: reel.steps, actor: reel.owner, round: reel.round }; } } } @@ -219,8 +220,7 @@ const httpServer = createServer((req, res) => { const data = shareData(watch[1]!); if (!data) { res.writeHead(404).end("no such replay"); return; } if (watch[2]) { - const last = data.steps[data.steps.length - 1]!; - const png = renderSharePng(last.view as Parameters[0]); + const png = renderSharePng(data.steps[data.steps.length - 1]!.view); res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" }); res.end(png); return; @@ -628,7 +628,7 @@ wss.on("connection", (socket) => { if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" }); const now = Date.now(); if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) { - return send(socket, { type: "error", message: "one moment" }); + return send(socket, { type: "error", message: "catching up already — one moment" }); } session.lastCatchUpAt = now; // turn -1 shares the whole finished game; anything else, one turn. diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index b284009..76974a5 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -447,8 +447,8 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): const MAX_STEPS = 80; const { state: fresh, events: dealt } = createGame(room.state.config); let current = fresh; - // The deal's own events open the first turn — count them, or every - // turn number would sit one behind the chronicle's. + // The deal's own events open the first turn — count them so turn + // numbers match the client chronicle's. let counter = -1; let owner: PlayerId | null = null; let round = 0; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a0f4ab8..75b78a7 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -161,7 +161,7 @@ /** Leafing through the face-up discard pile. */ let showDiscards = $state(false); let chatDraft = $state(""); - let botTier = $state(prefs.botTier); + let botTier = $state(prefs.botTier); /** Card whose official FAQ rulings are open. */ let faqCardId = $state(null); /** A discard-pile card enlarged above the pile. */ @@ -1395,9 +1395,9 @@
Automatons default to - {#each ["apprentice", "adept", "archmage"] as t (t)} + {#each ["apprentice", "adept", "archmage"] as const as t (t)} + onclick={() => { setPref("botTier", t); botTier = t; }}>{t} {/each}