From a1fdb37c5482fa759579a4df4f66e019d05b9184 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 30 Aug 2026 13:58:02 -0400 Subject: [PATCH] Credibility pass: the splice marks sanded from the rev-11 batch The extracted rebuildRoom loop reflowed to the file's indent and its doc comments unstacked; liftIdiotIfSatisfied moves below idiotBlocked so each keeps its own comment; the pickup's duplicated owner lookup folds to one; the cloned grab-refusal chain hoists to grabRefused; a one-off #8a7a5c rejoins the #8a7a5e palette; an inert overflow-x line and a duplicate import go; span 6 and the two cache strategies say why; the reversed walking-dead test moves beside its biting sibling. Also: the replay harness now honors lobby kicks, which VVZU (five bots seated, two kicked) was the first production ledger to exercise. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- deploy/replay-verify.mjs | 10 ++- packages/engine/src/game.ts | 19 +++-- packages/engine/test/creatures.test.ts | 2 + packages/engine/test/expansion-combat.test.ts | 26 +++++++ packages/engine/test/stones.test.ts | 28 +------ packages/server/src/index.ts | 5 +- packages/server/src/rooms.ts | 74 +++++++++---------- packages/web/src/App.svelte | 19 +++-- 8 files changed, 96 insertions(+), 87 deletions(-) diff --git a/deploy/replay-verify.mjs b/deploy/replay-verify.mjs index c507b05..8f9d2f3 100644 --- a/deploy/replay-verify.mjs +++ b/deploy/replay-verify.mjs @@ -8,8 +8,14 @@ if (!start) { console.log(`OK ${process.argv[2].split("/").pop()}: lobby only, nothing to replay`); process.exit(0); } -const joins = lines.filter((l) => l.kind === "join"); -const playerIds = [...new Set([meta.hostId, ...joins.map((j) => j.name)])]; +// The lobby roster: joins add seats, kicks remove them, the start freezes it. +const roster = new Set([meta.hostId]); +for (const l of lines) { + if (l.kind === "join") roster.add(l.name); + if (l.kind === "kick") roster.delete(l.name); + if (l.kind === "start") break; +} +const playerIds = [...roster]; let { state } = createGame({ playerIds, seed: meta.seed, sets: start.expansion ? ["basic", "expansion1"] : ["basic"], colors: start.colors, deckRev: start.deckRev }); let n = 0; for (const l of lines) { diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 326ffc8..ba55c46 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4568,6 +4568,13 @@ function castingBlocked( * treasure on their own home, or drop their own treasure underfoot for an * instant cure. */ +function idiotBlocked(state: GameState, playerId: PlayerId): string | null { + if (sustainedOn(state, playerId, "idiot").length > 0) { + return "What am I doing here...? (you can do nothing but head for your treasure)"; + } + return null; +} + /** IDIOT lifts when the march is done — the victim stands on their own * treasure — or turns unsatisfiable: every one of their treasures is in * someone's arms, leaving nothing to stand on. */ @@ -4584,13 +4591,6 @@ function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerSt state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id)); } -function idiotBlocked(state: GameState, playerId: PlayerId): string | null { - if (sustainedOn(state, playerId, "idiot").length > 0) { - return "What am I doing here...? (you can do nothing but head for your treasure)"; - } - return null; -} - /** "One cannot attack or be attacked while in a bush"; mist-bodies neither. */ function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null { if (sustainedOn(state, target.id, "big-man").length > 0 && @@ -6423,13 +6423,12 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult { const events: GameEvent[] = [ { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, ]; + const owner = state.players.find((q) => q.id === t.owner); // This grab may have lifted the owner's IDIOT: with every one of their // treasures now carried, there is nothing left to march to. - const cursedOwner = state.players.find((q) => q.id === t.owner); - if (cursedOwner) liftIdiotIfSatisfied(state, events, cursedOwner); + if (owner) liftIdiotIfSatisfied(state, events, owner); // WARD: "you may play at that time (out of turn) this card on him" — // literally: the grab hangs while the owner decides. - const owner = state.players.find((q) => q.id === t.owner); if (owner && owner.alive && owner.id !== p.id) { const holdsWard = owner.hand.some((c) => c.cardId === "ward"); if (holdsWard) { diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 9d22087..4bd294f 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -412,6 +412,8 @@ describe("big man", () => { }); it("a shrunk wizard slips between the giant's boots", () => { + // bigRig() makes the ACTIVE player the giant; here the mover must act, + // so the giant takes the other seat. let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); state = toRound2(state); const mover = activePlayer(state); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index da343fd..3e471d4 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -58,6 +58,32 @@ describe("expansion combat cards", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1); }); + it("a reversed walking dead heals half a point per space walked (rev 11)", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const wd = giveCard(state, attacker, "walking-dead"); + giveCard(state, defender, "reverse", "RV", 0); + const r = applyCommand(state, attacker, { + type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender }, + }); + if (!r.ok) throw new Error(r.error); + state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" }); + state = drain(state); + expect(sustainedOn(state, defender, "walking-dead").length).toBe(1); + + state = must(state, attacker, { type: "endTurn", draw: 0 }); + // Two spaces walked: one point gained, not bled. + let steps = 0; + for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) { + if (steps >= 2) break; + const mv = applyCommand(state, defender, { type: "move", direction: dir }); + if (mv.ok) { state = mv.state; steps++; } + } + expect(steps).toBe(2); + expect(state.players.find((p) => p.id === defender)!.life).toBe(16); + }); + it("mental swap trades entire hands", () => { let { state } = newGame(); state = toRound2(state); diff --git a/packages/engine/test/stones.test.ts b/packages/engine/test/stones.test.ts index f2777fd..d21bd46 100644 --- a/packages/engine/test/stones.test.ts +++ b/packages/engine/test/stones.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game"; +import { applyCommand, activePlayer, createGame, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game"; import type { CardInstance } from "../src/cards"; -import { createGame } from "../src/game"; import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; /** Display a stone for a player during their turn. */ @@ -170,31 +169,6 @@ describe("slow death", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(17); }); - it("a reversed walking dead heals half a point per space walked (rev 11)", () => { - let { state } = newGame(); - state = toRound2(state); - const { attacker, defender } = faceOff(state); - const wd = giveCard(state, attacker, "walking-dead"); - giveCard(state, defender, "reverse", "RV", 0); - const r = applyCommand(state, attacker, { - type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender }, - }); - if (!r.ok) throw new Error(r.error); - state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" }); - state = drain(state); - expect(sustainedOn(state, defender, "walking-dead").length).toBe(1); - - state = must(state, attacker, { type: "endTurn", draw: 0 }); - // Two spaces walked: one point gained, not bled. - let steps = 0; - for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) { - if (steps >= 2) break; - const mv = applyCommand(state, defender, { type: "move", direction: dir }); - if (mv.ok) { state = mv.state; steps++; } - } - expect(steps).toBe(2); - expect(state.players.find((p) => p.id === defender)!.life).toBe(16); - }); it("a full reflection returns the curse onto its caster (rev 11)", () => { let { state } = newGame(); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7f6f0a3..42443ea 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -225,7 +225,10 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string { ]; return ogPage(metas); } -/** Rendered share cards, by share id (immutable once minted). */ + +/** Rendered share cards, by share id. Shares are immutable, so unlike the + * TTL'd shareCache above this never invalidates — it only rotates out the + * oldest entry when full. */ const ogPngCache = new Map(); const OG_PNG_CACHE_MAX = 200; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 5cecd81..390cf56 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -651,7 +651,6 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI return { roomId: t.roomId, name: t.name, token: t.token }; } -/** Rebuild every persisted room by replaying its file. */ /** Rebuild one room from its ledger lines — the seed plus the log IS the * game. Returns null for an abandoned room; throws on a corrupt ledger. */ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { @@ -676,46 +675,47 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { }; if (lines.some((l) => l.kind === "abandon")) return null; for (const line of lines.slice(1)) { - if (line.kind === "kick") { - room.players = room.players.filter((p) => p !== line.name); - room.tokens.delete(line.name); - room.bots.delete(line.name); - room.colorChoices.delete(line.name); - } else if (line.kind === "join") { - if (line.bot) { - room.players.push(line.name); - room.bots.set(line.name, { - style: (line.style as AutomatonStyle) ?? "hunter", - secret: line.secret === true, - // Tier-less join lines predate tiers, when every automaton - // played the full repertoire — not the "adept" lobby default. - tier: (line.tier as AutomatonTier) ?? "archmage", - }); - } else { - const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); - if (!joinHash) throw new Error("join line has no token"); - room.players.push(line.name); - room.tokens.set(line.name, joinHash); - } - } else if (line.kind === "start") { - const r = startInMemory(room, line.expansion, line.colors, line.deckRev); - if ("error" in r) throw new Error(`replay start failed: ${r.error}`); - } else if (line.kind === "chat") { - // File order preserves the interleaving with commands. - room.chat.push({ player: line.player, text: line.text, at: line.at }); - room.events.push({ type: "tableTalk", player: line.player, text: line.text }); - } else if (line.kind === "command") { - if (!room.state) throw new Error("command before start in log"); - const result = applyCommand(room.state, line.playerId, line.command as Command); - if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`); - room.state = result.state; - room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at }); - room.events.push(...result.events); - } + if (line.kind === "kick") { + room.players = room.players.filter((p) => p !== line.name); + room.tokens.delete(line.name); + room.bots.delete(line.name); + room.colorChoices.delete(line.name); + } else if (line.kind === "join") { + if (line.bot) { + room.players.push(line.name); + room.bots.set(line.name, { + style: (line.style as AutomatonStyle) ?? "hunter", + secret: line.secret === true, + // Tier-less join lines predate tiers, when every automaton + // played the full repertoire — not the "adept" lobby default. + tier: (line.tier as AutomatonTier) ?? "archmage", + }); + } else { + const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); + if (!joinHash) throw new Error("join line has no token"); + room.players.push(line.name); + room.tokens.set(line.name, joinHash); + } + } else if (line.kind === "start") { + const r = startInMemory(room, line.expansion, line.colors, line.deckRev); + if ("error" in r) throw new Error(`replay start failed: ${r.error}`); + } else if (line.kind === "chat") { + // File order preserves the interleaving with commands. + room.chat.push({ player: line.player, text: line.text, at: line.at }); + room.events.push({ type: "tableTalk", player: line.player, text: line.text }); + } else if (line.kind === "command") { + if (!room.state) throw new Error("command before start in log"); + const result = applyCommand(room.state, line.playerId, line.command as Command); + if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`); + room.state = result.state; + room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at }); + room.events.push(...result.events); + } } return room; } +/** Rebuild every persisted room by replaying its file. */ export function loadPersistedRooms(): void { ensureDataDir(); let restored = 0; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index d474bf1..5b0c69d 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -102,8 +102,8 @@ } /** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded * squares, with the viewport scrolling. The svg's width is always set - * explicitly (fit × zoom) from measurement — auto-sizing left stale - * dimensions behind when the first-person pane closed. */ + * explicitly (fit × zoom) from measurement — auto-sizing leaves stale + * dimensions behind when the first-person pane closes. */ let boardViewportEl = $state(null); let boardZoom = $state(1); function zoomBoard(dir: number) { @@ -1079,16 +1079,14 @@ const weakened = view.sustained.some((e) => e.cardId === "weakness" && e.targetId === view.you); const safed = view.squareContents[here]?.kind === "safe" && view.squareContents[here]!.createdBy !== view.you && !view.openSafes.includes(here); - if (treasuresHere.length > 0 && !autoWillGrab && !me.carriedTreasureId && - !view.turn.actionsEnded && !weakened && !view.gluedCells[here] && !safed && - here !== cellKey(me.home)) { + const grabRefused = !!me.carriedTreasureId || view.turn.actionsEnded || weakened || + !!view.gluedCells[here] || safed || here === cellKey(me.home); + if (treasuresHere.length > 0 && !autoWillGrab && !grabRefused) { reasons.push(`a treasure lies at your feet, un-grabbed`); } // Auto-grab about to pocket your OWN chest: carrying it does not // score and bares it to capture, so that walk-off asks first. - if (autoWillGrab && !me.carriedTreasureId && !view.turn.actionsEnded && - !weakened && !view.gluedCells[here] && !safed && here !== cellKey(me.home) && - treasuresHere[0]!.owner === view.you) { + if (autoWillGrab && !grabRefused && treasuresHere[0]!.owner === view.you) { reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along"); } // The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves. @@ -3053,6 +3051,8 @@ .game.hand-left .table-edge > * { grid-column: 2; min-width: 0; } .game.hand-left .table-edge > .hand { grid-column: 1; + /* One implicit row per table-edge sibling; the strip deals at + * most six, and the hand must span them all to hold the column. */ grid-row: 1 / span 6; display: grid; grid-template-columns: repeat(2, 8.2rem); @@ -3063,7 +3063,6 @@ top: 0.5rem; max-height: calc(100dvh - 5rem); overflow-y: auto; - overflow-x: visible; padding: 0.3rem 0.3rem 0.9rem; } .game.hand-left .board-zone { grid-column: 2; grid-row: 1; } @@ -3120,7 +3119,7 @@ .zoom-btn { width: 1.7rem; height: 1.7rem; - border: 1px solid #8a7a5c; + border: 1px solid #8a7a5e; border-radius: 4px; background: rgba(233, 225, 203, 0.9); color: #43331f;