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}