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:
co-authored by
Claude Fable 5
parent
295ad2ad55
commit
45666bca16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user