Ambushes: the async-native form of Interrupt and Opportunity Fire

Eric's diagnosis: "in the moment" interruption cards are worthless in
correspondence play — there is no moment. The answer was already in
the game: WARD is a contingency card, and ambushes generalize it. On
your turn, playing Interrupt or Opportunity Fire now arms an ambush:
commit it with an attack from your hand (plus an optional number
card) and a trigger — an opponent entering your line of sight, coming
beside you, or grabbing a treasure — and it springs automatically
when the condition occurs, whether you are watching or asleep. The
sprung attack opens the normal counteraction stack, so the victim
gets their defense (asynchronously, like any attack). Committed cards
leave your hand until the trap springs or you disarm it; ambushes are
invisible to everyone but their owner (view-level redaction), die
with their owner, stay armed if the shot is momentarily illegal, and
honor the no-combat first round. Live play in the moment still works
too — and the client now actually offers it (the old UI never let
you click those cards out of turn). The rail shows your armed traps
with a disarm control; the chronicle announces AMBUSH! when one
springs. 124 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 01:36:53 -04:00
co-authored by Claude Fable 5
parent 295ad2ad55
commit 45666bca16
5 changed files with 329 additions and 3 deletions
+67 -1
View File
@@ -2,13 +2,15 @@ import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
boardView,
createGame,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey } from "../src/board";
import { cellKey, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
@@ -266,3 +268,67 @@ describe("add for movement", () => {
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-4#N3" }).ok).toBe(false);
});
});
describe("ambushes (async interrupts)", () => {
it("an armed Opportunity Fire springs when prey walks into sight", () => {
let { state } = newGame();
state = toRound2(state);
const owner = activePlayer(state);
const of_ = giveCard(state, owner.id, "opportunity-fire", "OF", 0);
const fb = giveCard(state, owner.id, "fireball", "FB", 1);
state = must(state, owner.id, {
type: "setAmbush", instanceId: of_.instanceId, trigger: { kind: "los" },
spellInstanceId: fb.instanceId,
});
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "fireball")).toBe(false);
expect(state.ambushes.length).toBe(1);
state = must(state, owner.id, { type: "endTurn", draw: 0 });
const preyNow = state.players.find((p) => p.id !== owner.id)!;
const ownerNow = state.players.find((p) => p.id === owner.id)!;
// Find a step that goes from a no-LOS cell into a LOS cell.
let found: { from: { x: number; y: number }; side: "N" | "S" | "E" | "W" } | null = null;
outer: for (const key of Object.keys(state.board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (!gameLos(state, ownerNow.position, cell)) continue;
for (const side of ["N", "S", "E", "W"] as const) {
const dx = side === "E" ? 1 : side === "W" ? -1 : 0;
const dy = side === "S" ? 1 : side === "N" ? -1 : 0;
const from = { x: x - dx, y: y - dy };
if (!state.board.cells[cellKey(from)]) continue;
if (gameLos(state, ownerNow.position, from)) continue;
const st = stepTarget(boardView(state), from, side);
if (st.kind === "step" && cellKey(st.to) === key) { found = { from, side }; break outer; }
}
}
expect(found).not.toBeNull();
preyNow.position = found!.from;
state = must(state, preyNow.id, { type: "move", direction: found!.side });
expect(state.stack).not.toBeNull();
expect(state.stack!.attackerId).toBe(owner.id);
expect(state.stack!.attackCard!.cardId).toBe("fireball");
expect(state.ambushes.length).toBe(0);
state = must(state, preyNow.id, { type: "pass" });
expect(state.players.find((p) => p.id === preyNow.id)!.life).toBe(10);
});
it("cancelling an ambush returns the committed cards", () => {
let { state } = newGame();
const owner = activePlayer(state);
const int_ = giveCard(state, owner.id, "interrupt", "I", 0);
const lb = giveCard(state, owner.id, "lightning-blast", "LB", 1);
giveCard(state, owner.id, "number-3", "N", 2);
state = must(state, owner.id, {
type: "setAmbush", instanceId: int_.instanceId, trigger: { kind: "near" },
spellInstanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
});
const id = state.ambushes[0]!.id;
state = must(state, owner.id, { type: "cancelAmbush", ambushId: id });
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "interrupt")).toBe(true);
expect(o.hand.some((c) => c.cardId === "lightning-blast")).toBe(true);
expect(state.ambushes.length).toBe(0);
});
});