From 94ec714e352867f664b02dee444a5bec4ba0ba4e Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 13:37:37 -0400 Subject: [PATCH] Rev 16: empathy mirrors the blows that resolve by their own hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/engine/src/game.ts | 20 +++++++++++++++++--- packages/engine/test/casting.test.ts | 23 +++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 40853a7..3d89e4a 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -1905,7 +1905,7 @@ const CARD_EFFECTS: Record if (d === 0) return; ctx.attacker.position = { ...ctx.defender.position }; 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": { @@ -1927,7 +1927,7 @@ const CARD_EFFECTS: Record ctx.attacker.carriedTreasureId = null; ctx.events.push({ type: "treasureThrown", attacker: ctx.attacker.id, at: ctx.defender.position, distance: d }); 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({ type: "treasureDropped", player: ctx.attacker.id, treasureId: t.id, @@ -1998,7 +1998,7 @@ const CARD_EFFECTS: Record if (!believed) return; const dmg = fx.baseDamage(ctx.stack.numberValue, ctx.stack.params ?? null); 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. */ function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number { const num = (numberInstanceIds ?? []) diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index f92f0ee..c80112f 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -947,3 +947,26 @@ describe("empathy as a counteraction", () => { 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); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 76ebe2b..4bbf0bc 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** 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"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index f3b76d6..7c7c309 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 15, + deckRev: 16, }; const { state, events } = createGame(config); for (const e of events) {