From 89ba225fe533600809dbff927f059143df617ec6 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 13:33:24 -0400 Subject: [PATCH] Illusions show their face, and empathy answers the call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automaton's ILLUSIONARY ATTACK arrived as a mystery: the modal showed only the illusion card, never the spell being faked — though the stack has always carried it face-up. The defender now sees both cards side by side, the caption names the fake ("an ILLUSIONARY Fireball — it bites only if believed"), and the hint bar says which spell to weigh a counter against. And EMPATHY — corner: COUNTERACTION — was refused at the door like INVISIBLE before it, while the automatons' own counter ladder had been reaching for it and silently failing into the fallback all along. It now raises mid-stack: the blow lands on both wizards, the link lingers for its NUMBER card's turns, and ANTI-ANTI severs it cleanly (empathy is no escape). The number-riding-a-counter idiom is one shared helper now. Permissive both ways: no stored ledger contains either play, so no rev gate. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 58 ++++++++++++++++++++-------- packages/engine/test/casting.test.ts | 41 ++++++++++++++++++++ packages/web/src/App.svelte | 24 +++++++++++- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 507c7eb..40853a7 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4628,6 +4628,17 @@ function checkAmbushes( } } +/** A NUMBER card riding a counteraction sets the raised spell's duration. */ +function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number { + const num = (numberInstanceIds ?? []) + .map((id) => player.hand.find((c) => c.instanceId === id)) + .find((c): c is CardInstance => c != null && isNumberCard(c.cardId)); + if (!num) return 1; + takeFromHand(player, num.instanceId); + state.discard.push(num); + return numberValue(num.cardId); +} + function doCounteract( prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }, numberInstanceIds?: string[], @@ -4710,16 +4721,7 @@ function doCounteract( if (stack.creatureId && (state.config.deckRev ?? 1) < 13) { return err("in this game's revision, creatures are not fooled by the vanishing"); } - let duration = 1; - const attached = (numberInstanceIds ?? []) - .map((id) => player.hand.find((c) => c.instanceId === id)) - .filter((c): c is CardInstance => c != null && isNumberCard(c.cardId)); - const num = attached[0]; - if (num) { - duration = numberValue(num.cardId); - takeFromHand(player, num.instanceId); - state.discard.push(num); - } + const duration = counterDuration(state, player, numberInstanceIds); takeFromHand(player, instanceId); state.discard.push(card); const events: GameEvent[] = [ @@ -4732,6 +4734,23 @@ function doCounteract( return { ok: true, state, events }; } + // EMPATHY (corner: COUNTERACTION): "Any attack done in any form against + // you acts against both you and the caster" — raised as the blow lands, + // lingering for the NUMBER card's duration after. + if (card.cardId === "empathy") { + const duration = counterDuration(state, player, numberInstanceIds); + takeFromHand(player, instanceId); + state.discard.push(card); + const events: GameEvent[] = [ + { type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }, + ]; + attachSustained(state, events, "empathy", playerId, playerId, duration); + stack.counters.push({ player: playerId, card, nullified: false }); + stack.waitingOn = stack.attackerId; + state.lastSpellUsed[playerId] = card.cardId; + return { ok: true, state, events }; + } + // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." // WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image. if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") { @@ -4822,12 +4841,17 @@ function doCounteract( takeFromHand(player, instanceId); state.discard.push(card); targetCounter.nullified = true; + const events: GameEvent[] = [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }]; + if (targetCounter.card.cardId === "empathy") { + // The raised link dies with its counteraction. + const fx = state.sustained.filter((f) => f.cardId === "empathy" && f.targetId === stack.defenderId).pop(); + if (fx) { + state.sustained = state.sustained.filter((f) => f !== fx); + events.push({ type: "spellExpired", effectId: fx.id, cardId: "empathy", target: stack.defenderId }); + } + } stack.waitingOn = stack.defenderId; - return { - ok: true, - state, - events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }], - }; + return { ok: true, state, events }; } return err("you are not part of this exchange"); @@ -4967,8 +4991,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void { pipe.fullyStopped = true; continue; } - if (counter.card.cardId === "invisible") { - continue; // its work is the sustained vanishing and the hit roll above + if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") { + continue; // their work is the sustained effect, not the damage pipe } if (counter.card.cardId === "teleport") { pipe.damage = 0; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 1c3a1e2..f92f0ee 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -906,3 +906,44 @@ describe("escapes and elemental walls as counteractions", () => { expect(refused.ok).toBe(false); }); }); + +describe("empathy as a counteraction", () => { + function empathyRig() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const fb = giveCard(state, attacker, "fireball"); + const d = state.players.find((p) => p.id === defender)!; + d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" }; + d.hand[1] = { instanceId: "number-3#T", cardId: "number-3" }; + state = must(state, attacker, { + type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, + }); + return { state, attacker, defender }; + } + + it("the blow lands on both — and the link lingers its number's turns", () => { + let { state, attacker, defender } = empathyRig(); + state = must(state, defender, { + type: "counteract", instanceId: "empathy#T", numberInstanceIds: ["number-3#T"], + }); + state = must(state, attacker, { type: "pass" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(10); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(10); + const fx = state.sustained.find((f) => f.cardId === "empathy" && f.targetId === defender); + expect(fx?.remainingTurns).toBe(3); + }); + + it("anti-anti severs the link: no mirror, no lingering spell", () => { + let { state, attacker, defender } = empathyRig(); + state = must(state, defender, { type: "counteract", instanceId: "empathy#T" }); + const a = state.players.find((p) => p.id === attacker)!; + a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" }; + state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(10); + expect(state.players.find((p) => p.id === attacker)!.life).toBe(15); + expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 4936105..82f8711 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1040,7 +1040,17 @@ {#if view.stack.defenderId === view.you && attackingCreature}
{:else if view.stack.defenderId === view.you && view.stack.attackCard} -
+ {@const illusionOf = view.stack.attackCard.cardId === "illusionary-attack" + ? (view.stack.params?.cardId ?? null) : null} + {#if illusionOf} +
+ (faqCardId = id)} /> + (faqCardId = id)} /> +
+
an ILLUSIONARY {cardDef(illusionOf).name} — it bites only if believed
+ {:else} +
+ {/if} {#if view.stack.numberValue != null}
powered by a {view.stack.numberValue}
{/if} @@ -1499,7 +1509,10 @@ : "Escaping by teleport — tap a square within four spaces to mark it."} {:else if view.stack.defenderId === view.you} {view.stack.attackerId} attacks with - {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — + {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` + : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId + ? `an illusionary ${cardDef(view.stack.params.cardId).name}` + : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — tap a counteraction card, or {:else} {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} @@ -2241,6 +2254,13 @@ } .attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; } .attack-card :global(.card:hover) { transform: scale(1.35); } + .attack-card.illusion-pair { + display: flex; + justify-content: center; + gap: 2.6rem; + } + .attack-card.illusion-pair :global(.card) { transform: scale(1.1); } + .attack-card.illusion-pair :global(.card:hover) { transform: scale(1.1); } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-standing { font-family: "Courier Prime", monospace;