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); + }); +});