Ambushed teleports carry their destination; room 59UN unstuck (rev 35)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-20 10:51:16 -04:00
co-authored by Claude Fable 5
parent e8c25ddc19
commit 76dd019d5d
5 changed files with 86 additions and 7 deletions
+19 -4
View File
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<Command, { type: "setAmbush"
numbers.push(c);
}
if (numbers.length > 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<Command, { type: "setAmbush"
trigger: cmd.trigger,
spell,
numbers,
...(cmd.cell ? { cell: { ...cmd.cell } } : {}),
};
state.ambushes.push(ambush);
return {
@@ -5058,7 +5073,7 @@ function checkAmbushes(
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
params: ambush.cell ? { cell: { ...ambush.cell } } : null,
kind: fx.physical ? "physical" : "spell",
counters: [],
waitingOn: actor.id,