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
+176
View File
@@ -85,6 +85,22 @@ export interface SustainedEffect {
edge?: string;
}
export type AmbushTrigger =
| { kind: "los" } // an opponent enters my line of sight
| { kind: "near" } // an opponent comes within one space of me
| { kind: "treasure" }; // an opponent picks up any treasure
export interface AmbushState {
id: string;
ownerId: PlayerId;
/** The card that grants the interruption. */
via: CardInstance;
trigger: AmbushTrigger;
/** The committed attack and its number cards, held out of the hand. */
spell: CardInstance;
numbers: CardInstance[];
}
/** A summoned creature (or SHADOW/ALTER EGO double). */
export interface CreatureState {
id: string;
@@ -217,6 +233,11 @@ export interface GameState {
dimWarps: { a: Cell; b: Cell }[];
/** INTERRUPT / OPPORTUNITY FIRE: one out-of-turn action window. */
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** Armed ambushes: an Interrupt/Opportunity Fire committed with an attack
* and a trigger, springing automatically — the async form of "in the
* moment" interruption. Hidden from everyone but the owner. */
ambushes: AmbushState[];
nextAmbushId: number;
players: PlayerState[];
treasures: TreasureState[];
sustained: SustainedEffect[];
@@ -493,6 +514,9 @@ export type GameEvent =
| { type: "outOfTurnWindow"; player: PlayerId; kind: "interrupt" | "opportunity-fire" }
| { type: "thumbOfGod"; caster: PlayerId; aimedAt: Cell; landedAt: Cell }
| { type: "tokenScattered"; what: string; from: Cell; to: Cell }
| { type: "ambushSet"; visibleTo: PlayerId; ambushId: string; via: string; spell: string; trigger: AmbushTrigger }
| { type: "ambushCancelled"; visibleTo: PlayerId; ambushId: string }
| { type: "ambushSprung"; owner: PlayerId; victim: PlayerId; via: string; spellCardId: string; trigger: AmbushTrigger }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number }
@@ -557,6 +581,8 @@ export type Command =
target?: CastTarget;
params?: CastParams;
}
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
| { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string }
| { type: "pass" }
| { type: "pickUpTreasure" }
@@ -2939,6 +2965,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
enchantedObjects: {},
dimWarps: [],
outOfTurnWindow: null,
ambushes: [],
nextAmbushId: 1,
players,
treasures,
sustained: [],
@@ -3075,6 +3103,8 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command);
case "cancelAmbush": return doCancelAmbush(state, command.ambushId);
case "counteract": return err("nothing to counteract");
case "pass": return err("nothing to pass on");
case "pickUpTreasure": return doPickUpTreasure(state);
@@ -3353,6 +3383,9 @@ function doMove(prev: GameState, direction: Side): CommandResult {
}
}
// Armed ambushes may spring on this step.
checkAmbushes(state, events, p, { movedFrom: from });
// BOOBYTRAP: the real token detonates under anyone but its caster.
for (const trap of [...state.boobytraps]) {
if (trap.casterId === p.id) continue;
@@ -3951,6 +3984,144 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
return { ok: true, state, events };
}
/** Arm an ambush: commit Interrupt/Opportunity Fire + an attack + a trigger. */
function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush" }>): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const owner = activePlayer(state);
const via = owner.hand.find((c) => c.instanceId === cmd.instanceId);
if (!via) return err("card not in hand");
if (via.cardId !== "interrupt" && via.cardId !== "opportunity-fire") {
return err("only Interrupt or Opportunity Fire can spring an ambush");
}
const spell = owner.hand.find((c) => c.instanceId === cmd.spellInstanceId);
if (!spell) return err("the attack to commit is not in your hand");
const fx = CARD_EFFECTS[spell.cardId];
if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush");
if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush");
const numbers: CardInstance[] = [];
for (const id of cmd.numberInstanceIds ?? []) {
const c = owner.hand.find((x) => x.instanceId === id);
if (!c || !isNumberCard(c.cardId)) return err("number card not in hand");
numbers.push(c);
}
if (numbers.length > 1) return err("one number card per action");
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
return err("choose a trigger: line of sight, close approach, or treasure");
}
// Commit the cards out of the hand; they return if the ambush is cancelled.
takeFromHand(owner, via.instanceId);
takeFromHand(owner, spell.instanceId);
for (const n of numbers) takeFromHand(owner, n.instanceId);
const ambush: AmbushState = {
id: `ambush-${state.nextAmbushId++}`,
ownerId: owner.id,
via,
trigger: cmd.trigger,
spell,
numbers,
};
state.ambushes.push(ambush);
return {
ok: true,
state,
events: [{
type: "ambushSet", visibleTo: owner.id, ambushId: ambush.id,
via: via.cardId, spell: spell.cardId, trigger: cmd.trigger,
}],
};
}
function doCancelAmbush(prev: GameState, ambushId: string): CommandResult {
const state = clone(prev);
const owner = activePlayer(state);
const idx = state.ambushes.findIndex((a) => a.id === ambushId && a.ownerId === owner.id);
if (idx === -1) return err("no such ambush of yours");
const [ambush] = state.ambushes.splice(idx, 1);
const p = state.players.find((q) => q.id === owner.id)!;
p.hand.push(ambush!.via, ambush!.spell, ...ambush!.numbers);
if (p.hand.length > handLimit(p)) state.pendingDiscard = p.id;
return {
ok: true,
state,
events: [{ type: "ambushCancelled", visibleTo: owner.id, ambushId }],
};
}
/**
* After an actor moves (or grabs a treasure), armed ambushes may spring: the
* committed attack fires at the triggering wizard through the normal
* counteraction stack. Fires at most one ambush per check.
*/
function checkAmbushes(
state: GameState,
events: GameEvent[],
actor: PlayerState,
context: { movedFrom?: Cell; pickedUpTreasure?: boolean },
): void {
if (state.stack || state.phase !== "playing") return;
if (state.turn.round === 1) return; // no combat during the first round
for (const ambush of [...state.ambushes]) {
if (ambush.ownerId === actor.id) continue;
const owner = state.players.find((p) => p.id === ambush.ownerId);
if (!owner || !owner.alive || !actor.alive) continue;
if (attackBlockedByStatus(state, owner, actor)) continue;
let sprung = false;
if (ambush.trigger.kind === "treasure") {
sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) {
if (ambush.trigger.kind === "los") {
const before = gameLos(state, owner.position, context.movedFrom);
const now = gameLos(state, owner.position, actor.position);
sprung = now && !before;
} else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) =>
Math.abs(owner.position.x - c.x) + Math.abs(owner.position.y - c.y);
sprung = dist(actor.position) <= 1 && dist(context.movedFrom) > 1;
}
}
if (!sprung) continue;
// The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue;
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
const numberValue = ambush.numbers.length > 0
? ambush.numbers.reduce((t, c) => t + (cardDef(c.cardId).value ?? 0), 0)
: null;
events.push({
type: "ambushSprung", owner: owner.id, victim: actor.id,
via: ambush.via.cardId, spellCardId: ambush.spell.cardId, trigger: ambush.trigger,
});
state.stack = {
attackerId: owner.id,
defenderId: actor.id,
attackCard: ambush.spell,
numberValue,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: fx.physical ? "physical" : "spell",
counters: [],
waitingOn: actor.id,
};
events.push({
type: "spellCast", caster: owner.id, card: ambush.spell, cardId: ambush.spell.cardId,
numberCards: ambush.numbers, numberValue,
from: owner.position, target: actor.id, targetCell: actor.position,
});
return; // one ambush per check; others may spring on later steps
}
}
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult {
const state = clone(prev);
const stack = state.stack!;
@@ -4295,6 +4466,10 @@ function applyDamage(
state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id);
// "If you die, any monster controlled by you immediately disappears."
state.creatures = state.creatures.filter((c) => c.controllerId !== target.id);
for (const a of state.ambushes.filter((a) => a.ownerId === target.id)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== target.id);
if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
@@ -4371,6 +4546,7 @@ function doPickUpTreasure(prev: GameState): CommandResult {
checkVictory(state, events);
}
}
checkAmbushes(state, events, p, { pickedUpTreasure: true });
return { ok: true, state, events };
}
+6
View File
@@ -6,6 +6,7 @@ import { type AssembledBoard } from "./board";
import { type CardInstance } from "./cards";
import {
boardView,
type AmbushState,
type CastStack,
type CreatureState,
type GameState,
@@ -62,6 +63,8 @@ export interface GameView {
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
@@ -118,6 +121,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
wandCharges: { ...state.wandCharges },
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })),
boobytraps: state.boobytraps.map((t) => {
const [rx, ry] = t.realKey.split(",").map(Number) as [number, number];
return {
+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);
});
});
+77 -2
View File
@@ -18,6 +18,10 @@
let hotseatCount = $state(2);
let setupName = $state("");
let setupColor = $state(0);
/** Ambush arming flow: the Interrupt/OF card, trigger, and committed attack. */
let ambushVia = $state<CardInstance | null>(null);
let ambushTrigger = $state<"los" | "near" | "treasure" | null>(null);
let ambushSpell = $state<CardInstance | null>(null);
// Default each new wizard to the first unclaimed standee.
$effect(() => {
if (local.setup && local.setup.colors.includes(setupColor)) {
@@ -98,6 +102,9 @@
}
function clearSelection() {
ambushVia = null;
ambushTrigger = null;
ambushSpell = null;
trapCells = [];
tradeFrom = null;
selectedCreature = null;
@@ -143,7 +150,31 @@
discardSelection = next;
return;
}
if (!isYourTurn) return;
if (!isYourTurn) {
// Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending).
if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
dispatch({ type: "cast", instanceId: card.instanceId });
}
return;
}
// On your own turn, Interrupt / Opportunity Fire arm an ambush instead.
if (card.cardId === "interrupt" || card.cardId === "opportunity-fire") {
clearSelection();
ambushVia = card;
return;
}
if (ambushVia) {
if (isNumberCard(card.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
return;
}
if (cardDef(card.cardId).cardType === "attack") {
ambushSpell = ambushSpell?.instanceId === card.instanceId ? null : card;
return;
}
return;
}
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
wbDamage = numberTotal;
@@ -206,6 +237,18 @@
clearSelection();
}
function armAmbush() {
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
dispatch({
type: "setAmbush",
instanceId: ambushVia.instanceId,
trigger: { kind: ambushTrigger },
spellInstanceId: ambushSpell.instanceId,
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
}
function castPowerRun() {
if (!selectedCard) return;
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
@@ -745,6 +788,18 @@
<div class="slip">{view.activePlayerId} is taking their turn…</div>
{/if}
{#if view.yourAmbushes.length > 0}
{#each view.yourAmbushes as a (a.id)}
<div class="slip ambush-note">
🗡 {cardDef(a.spell.cardId).name} waits —
{a.trigger.kind === "los" ? "when seen" : a.trigger.kind === "near" ? "when approached" : "when treasure is grabbed"}
{#if isYourTurn}
<button class="hint-cancel" onclick={() => dispatch({ type: "cancelAmbush", ambushId: a.id })}>disarm</button>
{/if}
</div>
{/each}
{/if}
<div class="scoresheet">
<div class="scoresheet-head">life-points</div>
{#each view.players as p (p.id)}
@@ -769,6 +824,23 @@
</div>
<div class="table-edge">
{#if ambushVia}
<div class="hint-strip">
<strong class="hint-name">{cardDef(ambushVia.cardId).name} — set an ambush</strong>
{#if !ambushTrigger}
<span>springs when an opponent…</span>
<button class="stamp tiny" onclick={() => (ambushTrigger = "los")}>enters my sight</button>
<button class="stamp tiny" onclick={() => (ambushTrigger = "near")}>comes beside me</button>
<button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
{:else if !ambushSpell}
<span>— now tap the attack card to commit</span>
{:else}
<span>{cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
<button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
{/if}
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
</div>
{/if}
{#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
{#if youMustDiscard}
@@ -875,8 +947,10 @@
{#each view.yourHand as card (card.instanceId)}
<Card
{card}
selected={selectedCard?.instanceId === card.instanceId}
selected={selectedCard?.instanceId === card.instanceId ||
ambushVia?.instanceId === card.instanceId}
attached={attachedNumber?.instanceId === card.instanceId ||
ambushSpell?.instanceId === card.instanceId ||
attachedMods.some((m) => m.instanceId === card.instanceId)}
marked={discardSelection.has(card.instanceId)}
displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
@@ -1243,6 +1317,7 @@
}
.slip.yours { border-left: 4px solid #2e7d32; }
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.slip.winner {
font-family: "Oswald", sans-serif;
+3
View File
@@ -120,6 +120,9 @@ export function humanize(e: GameEvent): string | null {
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`;
case "tokenScattered": return `${e.what} goes flying!`;
case "ambushSet": return `You commit ${e.spell} to an ambush (${e.via}).`;
case "ambushCancelled": return `You quietly disarm your ambush.`;
case "ambushSprung": return `AMBUSH! ${e.owner}'s hidden ${e.via.replace(/-/g, " ")} springs on ${e.victim}!`;
case "trapRedrawnDuringDeal": return null;
case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;