From 76dd019d5d919194601aaa4527db24a619577290 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 20 Aug 2026 10:51:16 -0400 Subject: [PATCH] Ambushed teleports carry their destination; room 59UN unstuck (rev 35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ambush springing TELEPORT OPPONENT opened a stack with params null — the spring bypasses cast validation and nothing ever asked where the victim goes — and resolution crashed on the missing cell, wedging the room 'waiting on Automaton'. Three layers: - Crash guards: teleport-opponent and mental-force fizzle gracefully on a destination-less stack (ungated: no stored command had resolved one). - The trap commits its destination when laid: setAmbush carries a cell ('wherever you say', said in advance), the spring passes it into the stack, and the client's arming flow asks for the click. - Rev 35 refuses arming those spells without a destination; older ledgers armed blind and their springs fizzle. All ledgers verified before deploy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/game.ts | 23 ++++++++++++--- packages/engine/test/game.test.ts | 48 +++++++++++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 18 +++++++++++- packages/web/src/local.svelte.ts | 2 +- 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 17a8efc..fea4cf6 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -103,6 +103,9 @@ export interface AmbushState { /** The committed attack and its number cards, held out of the hand. */ spell: CardInstance; numbers: CardInstance[]; + /** Destination chosen at arm time for cell-needing spells ("wherever + * you say", said in advance): teleport-opponent, mental-force. */ + cell?: Cell; } /** A summoned creature (or SHADOW/ALTER EGO double). */ @@ -683,7 +686,7 @@ export type Command = target?: CastTarget; params?: CastParams; } - | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] } + | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell } | { type: "cancelAmbush"; ambushId: string } | { type: "counteract"; instanceId: string; params?: { cell?: Cell; cardId?: string }; numberInstanceIds?: string[] } | { type: "pass" } @@ -950,7 +953,8 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return; - const to = ctx.stack.params!.cell!; + const to = ctx.stack.params?.cell; + if (!to) return; // an old ambush sprang it with no destination: fizzle const from = ctx.defender.position; ctx.defender.position = to; ctx.events.push({ @@ -2068,7 +2072,8 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return; - const to = ctx.stack.params!.cell!; + const to = ctx.stack.params?.cell; + if (!to) return; // an old ambush sprang it with no destination: fizzle if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return; const from = ctx.defender.position; ctx.defender.position = to; @@ -4959,6 +4964,15 @@ function doSetAmbush(prev: GameState, cmd: Extract 1) return err("one number card per action"); + // Cell-needing spells commit their destination when the trap is laid + // (rules rev 35; older games armed them blind and their springs fizzle). + const needsCell = spell.cardId === "teleport-opponent" || spell.cardId === "mental-force"; + if (needsCell && (prev.config.deckRev ?? 1) >= 35) { + if (!cmd.cell) return err("choose where the ambush will send them"); + const v = boardView(state); + if (!v.cells[cellKey(cmd.cell)]) return err("that destination is off the board"); + if (state.squareContents[cellKey(cmd.cell)]?.kind === "stone") return err("that destination is solid stone"); + } if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) { return err("choose a trigger: line of sight, close approach, or treasure"); } @@ -4974,6 +4988,7 @@ function doSetAmbush(prev: GameState, cmd: Extract { expect(r.ok).toBe(false); }); }); + +describe("an ambushed teleport carries its destination (rules rev 35)", () => { + it("the trap springs and the victim lands where the trapper said", () => { + let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 }); + for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) { + const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); + if (!r.ok) throw new Error(r.error); + state = r.state; + } + const trapper = state.players.find((p) => p.id === "trapper")!; + const prey = state.players.find((p) => p.id === "prey")!; + giveCard(state, "trapper", "interrupt", "I", 0); + giveCard(state, "trapper", "teleport-opponent", "TO", 1); + // Arming without a destination is refused; with one it is stored. + const bad = applyCommand(state, "trapper", { + type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" }, + spellInstanceId: "teleport-opponent#TO", + }); + expect(bad.ok).toBe(false); + const dest = { x: trapper.home.x, y: trapper.home.y === 0 ? 1 : trapper.home.y - 1 }; + let r = applyCommand(state, "trapper", { + type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" }, + spellInstanceId: "teleport-opponent#TO", cell: dest, + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok + ? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : state; + // The prey walks adjacent; the trap springs; the prey passes; they land + // at dest. (Re-find both: applyCommand clones made the handles stale.) + const trapperNow = state.players.find((p) => p.id === "trapper")!; + const preyNow = state.players.find((p) => p.id === "prey")!; + const below = trapperNow.position.y >= 2; + preyNow.position = { x: trapperNow.position.x, y: trapperNow.position.y + (below ? -2 : 2) }; + const dir = below ? "S" : "N"; + state.edgeOverrides[edgeKey(preyNow.position, dir)] = "open"; + r = applyCommand(state, "prey", { type: "move", direction: dir }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.stack?.attackCard?.cardId).toBe("teleport-opponent"); + expect(state.stack?.params?.cell).toEqual(dest); + r = applyCommand(state, "prey", { type: "pass" }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.players.find((p) => p.id === "prey")!.position).toEqual(dest); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 5d0f86c..4177419 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 34; +const RULES_REV = 35; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 076c4cf..7a640c2 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -321,6 +321,7 @@ ambushVia = null; ambushTrigger = null; ambushSpell = null; + ambushCell = null; trapCells = []; tradeFrom = null; selectedCreature = null; @@ -499,14 +500,21 @@ clearSelection(); } + /** Cell-needing ambush spells choose their destination when laid. */ + const ambushNeedsCell = $derived( + ambushSpell != null && + (ambushSpell.cardId === "teleport-opponent" || ambushSpell.cardId === "mental-force")); + let ambushCell = $state<{ x: number; y: number } | null>(null); function armAmbush() { if (!ambushVia || !ambushTrigger || !ambushSpell) return; + if (ambushNeedsCell && !ambushCell) return; dispatch({ type: "setAmbush", instanceId: ambushVia.instanceId, trigger: { kind: ambushTrigger }, spellInstanceId: ambushSpell.instanceId, ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), + ...(ambushCell ? { cell: ambushCell } : {}), }); clearSelection(); } @@ -519,6 +527,12 @@ function clickCell(cell: { x: number; y: number }) { if (!view) return; + // Laying an ambush with a cell-needing spell: this click is the trap's + // destination, chosen in advance ("wherever you say"). + if (ambushSpell && ambushNeedsCell && !ambushCell) { + ambushCell = cell; + return; + } if (youMustRespond && selectedCard?.cardId === "teleport") { // Mark first, jump on the second tap: an escape spent on a misclick // is an escape wasted. @@ -1887,8 +1901,10 @@ {:else if !ambushSpell} — now tap the attack card to commit + {:else if ambushNeedsCell && !ambushCell} + {cardDef(ambushSpell.cardId).name} — click where the trap will send them {:else} - {cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"} + {cardDef(ambushSpell.cardId).name}{ambushCell ? ` → (${ambushCell.x}, ${ambushCell.y})` : ""}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"} {/if} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index f71224d..73a779b 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 34, + deckRev: 35, }; const { state, events } = createGame(config); for (const e of events) {