From b210cffa76eb0fb6b12b327ebb7e1a0f8d6bba41 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 12:58:16 -0400 Subject: [PATCH] Zero-damage attacks stop lying about being stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DROP OBJECT resolved correctly — the treasure fell — but the chronicle announced "The attack is completely stopped," because the attackResolved event branded any attack that dealt zero damage as stopped. Utility attacks deal zero by design; only an attack that was TRYING to wound counts as stopped when nothing lands. Tests pin both sides: uncountered DROP OBJECT reports unstopped and drops the treasure; FULL SHIELD reports stopped and the treasure stays carried. Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 5 ++- packages/engine/test/casting.test.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 7b58085..c65a178 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4497,6 +4497,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void { ? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor : 0; + // Whether this attack even tries to wound: utility attacks (DROP OBJECT, + // TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped. + const dealsDamage = base > 0; const pipe: DamagePipeline = { damage: base, duration: baseDuration, @@ -4578,7 +4581,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { attackCardId: attackId, damageDealt, reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage, - fullyStopped: pipe.fullyStopped || (damageDealt === 0 && !pipe.redirected && !effect?.sustains), + fullyStopped: pipe.fullyStopped || (dealsDamage && damageDealt === 0 && !pipe.redirected && !effect?.sustains), redirected: pipe.redirected, }); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 78db78c..5c4d92e 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -487,3 +487,50 @@ describe("rules revision 3", () => { expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); }); }); + +describe("zero-damage utility attacks", () => { + it("an uncountered DROP OBJECT is not reported as stopped — and the treasure falls", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const d = state.players.find((p) => p.id === defender)!; + const treasure = state.treasures.find((t) => t.owner !== defender)!; + treasure.carriedBy = defender; + treasure.position = null; + d.carriedTreasureId = treasure.id; + const dob = giveCard(state, attacker, "drop-object"); + state = must(state, attacker, { + type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" }, + target: { kind: "player", playerId: defender }, + }); + const result = applyCommand(state, defender, { type: "pass" }); + if (!result.ok) throw new Error(result.error); + const resolved = result.events.find((e) => e.type === "attackResolved"); + expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(false); + expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBeNull(); + }); + + it("FULL SHIELD genuinely stops it — the treasure stays carried", () => { + let { state } = newGame(); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const d = state.players.find((p) => p.id === defender)!; + const treasure = state.treasures.find((t) => t.owner !== defender)!; + treasure.carriedBy = defender; + treasure.position = null; + d.carriedTreasureId = treasure.id; + const dob = giveCard(state, attacker, "drop-object"); + giveCard(state, defender, "full-shield", "FS", 0); + state = must(state, attacker, { + type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" }, + target: { kind: "player", playerId: defender }, + }); + state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" }); + state = must(state, attacker, { type: "pass" }); + const result = applyCommand(state, defender, { type: "pass" }); + if (!result.ok) throw new Error(result.error); + const resolved = result.events.find((e) => e.type === "attackResolved"); + expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true); + expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id); + }); +});