diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 8273600..7b79d39 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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): Comma return { ok: true, state, events }; } +/** Arm an ambush: commit Interrupt/Opportunity Fire + an attack + a trigger. */ +function doSetAmbush(prev: GameState, cmd: Extract): 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 }; } diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 2a8a204..a7463b1 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -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 { diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 3cb9e35..97adee7 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -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); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index caf26da..b170268 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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(null); + let ambushTrigger = $state<"los" | "near" | "treasure" | null>(null); + let ambushSpell = $state(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 @@
{view.activePlayerId} is taking their turn…
{/if} + {#if view.yourAmbushes.length > 0} + {#each view.yourAmbushes as a (a.id)} +
+ 🗡 {cardDef(a.spell.cardId).name} waits — + {a.trigger.kind === "los" ? "when seen" : a.trigger.kind === "near" ? "when approached" : "when treasure is grabbed"} + {#if isYourTurn} + + {/if} +
+ {/each} + {/if} +
life-points
{#each view.players as p (p.id)} @@ -769,6 +824,23 @@
+ {#if ambushVia} +
+ {cardDef(ambushVia.cardId).name} — set an ambush + {#if !ambushTrigger} + springs when an opponent… + + + + {:else if !ambushSpell} + — now tap the attack card to commit + {:else} + {cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"} + + {/if} + +
+ {/if} {#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
{#if youMustDiscard} @@ -875,8 +947,10 @@ {#each view.yourHand as card (card.instanceId)} 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; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 37a8a6e..a8b3003 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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.`;