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
+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 {