Illusions show their face, and empathy answers the call
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ea3b9e5bd6
commit
89ba225fe5
+41
-17
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1040,7 +1040,17 @@
|
||||
{#if view.stack.defenderId === view.you && attackingCreature}
|
||||
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
|
||||
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
|
||||
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
|
||||
{@const illusionOf = view.stack.attackCard.cardId === "illusionary-attack"
|
||||
? (view.stack.params?.cardId ?? null) : null}
|
||||
{#if illusionOf}
|
||||
<div class="attack-card illusion-pair">
|
||||
<Card card={{ instanceId: "illusion-named", cardId: illusionOf }} onfaq={(id) => (faqCardId = id)} />
|
||||
<Card card={view.stack.attackCard} onfaq={(id) => (faqCardId = id)} />
|
||||
</div>
|
||||
<div class="attack-power">an ILLUSIONARY {cardDef(illusionOf).name} — it bites only if believed</div>
|
||||
{:else}
|
||||
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
|
||||
{/if}
|
||||
{#if view.stack.numberValue != null}
|
||||
<div class="attack-power">powered by a {view.stack.numberValue}</div>
|
||||
{/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;
|
||||
|
||||
Reference in New Issue
Block a user