Rev 16: empathy mirrors the blows that resolve by their own hand

A believed ILLUSIONARY ATTACK, the goat's ram, and the hurled
treasure all deal their damage inside the attack's own resolution
hook — past the pipeline where the empathy mirror lived, so a linked
caster walked away unbloodied. One resolvedBlow helper now carries
the mirror to all three: "any attack done in ANY form" means these
too. Rev-gated: proactive empathy predates this, so stored ledgers
may hold unmirrored blows and replay them as struck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 13:37:37 -04:00
co-authored by Claude Fable 5
parent 89ba225fe5
commit 94ec714e35
4 changed files with 42 additions and 5 deletions
+17 -3
View File
@@ -1905,7 +1905,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (d === 0) return; if (d === 0) return;
ctx.attacker.position = { ...ctx.defender.position }; ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
applyDamage(ctx.state, ctx.events, ctx.defender, d, "goat ram", ctx.attacker.id, "physical"); resolvedBlow(ctx, d, "goat ram", "physical");
}, },
}, },
"heave-ho": { "heave-ho": {
@@ -1927,7 +1927,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
ctx.attacker.carriedTreasureId = null; ctx.attacker.carriedTreasureId = null;
ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d }); ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d });
if (!ctx.fullyStopped && ctx.defender.alive && d > 0) { if (!ctx.fullyStopped && ctx.defender.alive && d > 0) {
applyDamage(ctx.state, ctx.events, ctx.defender, d, "hurled treasure", ctx.attacker.id, "physical"); resolvedBlow(ctx, d, "hurled treasure", "physical");
} }
ctx.events.push({ ctx.events.push({
type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id, type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id,
@@ -1998,7 +1998,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!believed) return; if (!believed) return;
const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null); const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null);
if (dmg > 0) { if (dmg > 0) {
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, `illusionary ${chosen}`, ctx.attacker.id); resolvedBlow(ctx, dmg, `illusionary ${chosen}`);
} }
}, },
}, },
@@ -4628,6 +4628,20 @@ function checkAmbushes(
} }
} }
/** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster" (rules rev 16; earlier ledgers resolved these blows
* unmirrored and replay so). */
function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void {
applyDamage(ctx.state, ctx.events, ctx.defender, dmg, source, ctx.attacker.id, kind);
if ((ctx.state.config.deckRev ?? 1) >= 16 && dmg > 0 && ctx.attacker.alive &&
sustainedOn(ctx.state, ctx.defender.id, "empathy").length > 0) {
applyDamage(ctx.state, ctx.events, ctx.attacker, dmg, `${source} (empathy)`, ctx.defender.id, kind);
}
}
/** A NUMBER card riding a counteraction sets the raised spell's duration. */ /** A NUMBER card riding a counteraction sets the raised spell's duration. */
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number { function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
const num = (numberInstanceIds ?? []) const num = (numberInstanceIds ?? [])
+23
View File
@@ -947,3 +947,26 @@ describe("empathy as a counteraction", () => {
expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false); expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false);
}); });
}); });
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
it("a believed illusion bites both wizards", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
state = must(state, attacker, {
type: "cast", instanceId: ill.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "fireball" },
});
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const dAfter = state.players.find((p) => p.id === defender)!;
const aAfter = state.players.find((p) => p.id === attacker)!;
// Whichever way belief rolled, the two lives moved in lockstep.
expect(15 - aAfter.life).toBe(15 - dAfter.life);
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 15; const RULES_REV = 16;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}), ...(colors ? { colors } : {}),
deckRev: 15, deckRev: 16,
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
for (const e of events) { for (const e of events) {