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:
co-authored by
Claude Fable 5
parent
e8c25ddc19
commit
76dd019d5d
@@ -103,6 +103,9 @@ export interface AmbushState {
|
|||||||
/** The committed attack and its number cards, held out of the hand. */
|
/** The committed attack and its number cards, held out of the hand. */
|
||||||
spell: CardInstance;
|
spell: CardInstance;
|
||||||
numbers: 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). */
|
/** A summoned creature (or SHADOW/ALTER EGO double). */
|
||||||
@@ -683,7 +686,7 @@ export type Command =
|
|||||||
target?: CastTarget;
|
target?: CastTarget;
|
||||||
params?: CastParams;
|
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: "cancelAmbush"; ambushId: string }
|
||||||
| { type: "counteract"; instanceId: string; params?: { cell?: Cell; cardId?: string }; numberInstanceIds?: string[] }
|
| { type: "counteract"; instanceId: string; params?: { cell?: Cell; cardId?: string }; numberInstanceIds?: string[] }
|
||||||
| { type: "pass" }
|
| { type: "pass" }
|
||||||
@@ -950,7 +953,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) 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;
|
const from = ctx.defender.position;
|
||||||
ctx.defender.position = to;
|
ctx.defender.position = to;
|
||||||
ctx.events.push({
|
ctx.events.push({
|
||||||
@@ -2068,7 +2072,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
onResolved: (ctx) => {
|
onResolved: (ctx) => {
|
||||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||||
if (isLockedInPlace(ctx.state, ctx.defender.id)) 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;
|
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
|
||||||
const from = ctx.defender.position;
|
const from = ctx.defender.position;
|
||||||
ctx.defender.position = to;
|
ctx.defender.position = to;
|
||||||
@@ -4959,6 +4964,15 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
|
|||||||
numbers.push(c);
|
numbers.push(c);
|
||||||
}
|
}
|
||||||
if (numbers.length > 1) return err("one number card per action");
|
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)) {
|
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
|
||||||
return err("choose a trigger: line of sight, close approach, or treasure");
|
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,
|
trigger: cmd.trigger,
|
||||||
spell,
|
spell,
|
||||||
numbers,
|
numbers,
|
||||||
|
...(cmd.cell ? { cell: { ...cmd.cell } } : {}),
|
||||||
};
|
};
|
||||||
state.ambushes.push(ambush);
|
state.ambushes.push(ambush);
|
||||||
return {
|
return {
|
||||||
@@ -5058,7 +5073,7 @@ function checkAmbushes(
|
|||||||
amplifyFactor: 1,
|
amplifyFactor: 1,
|
||||||
extendFactor: 1,
|
extendFactor: 1,
|
||||||
powerAttackPoints: 0,
|
powerAttackPoints: 0,
|
||||||
params: null,
|
params: ambush.cell ? { cell: { ...ambush.cell } } : null,
|
||||||
kind: fx.physical ? "physical" : "spell",
|
kind: fx.physical ? "physical" : "spell",
|
||||||
counters: [],
|
counters: [],
|
||||||
waitingOn: actor.id,
|
waitingOn: actor.id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { giveCard } from "./helpers";
|
import { giveCard } from "./helpers";
|
||||||
|
import { edgeKey } from "../src/board";
|
||||||
import {
|
import {
|
||||||
applyCommand,
|
applyCommand,
|
||||||
activePlayer,
|
activePlayer,
|
||||||
@@ -312,3 +313,50 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
|
|||||||
expect(r.ok).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface Room {
|
|||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
|
|
||||||
/** Rules revision new games are dealt under (stored games keep their own). */
|
/** 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";
|
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,7 @@
|
|||||||
ambushVia = null;
|
ambushVia = null;
|
||||||
ambushTrigger = null;
|
ambushTrigger = null;
|
||||||
ambushSpell = null;
|
ambushSpell = null;
|
||||||
|
ambushCell = null;
|
||||||
trapCells = [];
|
trapCells = [];
|
||||||
tradeFrom = null;
|
tradeFrom = null;
|
||||||
selectedCreature = null;
|
selectedCreature = null;
|
||||||
@@ -499,14 +500,21 @@
|
|||||||
clearSelection();
|
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() {
|
function armAmbush() {
|
||||||
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
|
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
|
||||||
|
if (ambushNeedsCell && !ambushCell) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "setAmbush",
|
type: "setAmbush",
|
||||||
instanceId: ambushVia.instanceId,
|
instanceId: ambushVia.instanceId,
|
||||||
trigger: { kind: ambushTrigger },
|
trigger: { kind: ambushTrigger },
|
||||||
spellInstanceId: ambushSpell.instanceId,
|
spellInstanceId: ambushSpell.instanceId,
|
||||||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||||||
|
...(ambushCell ? { cell: ambushCell } : {}),
|
||||||
});
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
@@ -519,6 +527,12 @@
|
|||||||
|
|
||||||
function clickCell(cell: { x: number; y: number }) {
|
function clickCell(cell: { x: number; y: number }) {
|
||||||
if (!view) return;
|
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") {
|
if (youMustRespond && selectedCard?.cardId === "teleport") {
|
||||||
// Mark first, jump on the second tap: an escape spent on a misclick
|
// Mark first, jump on the second tap: an escape spent on a misclick
|
||||||
// is an escape wasted.
|
// is an escape wasted.
|
||||||
@@ -1887,8 +1901,10 @@
|
|||||||
<button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
|
<button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
|
||||||
{:else if !ambushSpell}
|
{:else if !ambushSpell}
|
||||||
<span>— now tap the attack card to commit</span>
|
<span>— now tap the attack card to commit</span>
|
||||||
|
{:else if ambushNeedsCell && !ambushCell}
|
||||||
|
<span>{cardDef(ambushSpell.cardId).name} — click where the trap will send them</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
|
<span>{cardDef(ambushSpell.cardId).name}{ambushCell ? ` → (${ambushCell.x}, ${ambushCell.y})` : ""}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
|
||||||
<button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
|
<button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
|
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ class LocalGame {
|
|||||||
seed,
|
seed,
|
||||||
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
sets: expansion ? ["basic", "expansion1"] : ["basic"],
|
||||||
...(colors ? { colors } : {}),
|
...(colors ? { colors } : {}),
|
||||||
deckRev: 34,
|
deckRev: 35,
|
||||||
};
|
};
|
||||||
const { state, events } = createGame(config);
|
const { state, events } = createGame(config);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
|
|||||||
Reference in New Issue
Block a user