From 019deedd85ebae69bedb3de9d605618740fc5292 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 28 Aug 2026 23:36:48 -0400 Subject: [PATCH] The imp's fire is a spell; the soulstone floor holds (rules rev 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eric flagged both mid-game, and the second one killed me while he typed: the scorch carried no damage kind, slipping past SOULSTONE entirely, and the stone's own clamp guarded only from above — a bearer already at 3 or less had no protection at all, the exact opposite of "last three points cannot be taken by spell attack." At rev 8 the floor holds from below (spell damage at or under the floor is simply immune), and per the FAQ the scorch is a SPELL: it opens a counteraction window, burns as magical fire, and FULL SHIELD parts it. One victim's window at a time; the sweep finds the rest next pass. Older games — including tonight's, where the flaw was fatal — replay their recorded flames unchanged; all 34 ledgers verify. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 48 +++++++++++++--- packages/engine/test/creatures.test.ts | 76 ++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 177a755..06c5f75 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -236,8 +236,10 @@ export interface CastParams { * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would * be an attack") instead of resolving instantly. * Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster - * carries it, sharing a square bites both directions, no counteraction. */ -export const CURRENT_RULES_REV = 7; + * carries it, sharing a square bites both directions, no counteraction. + * Rev 8: the fire imp's scorch is a SPELL (FAQ) — counteractable, magical — + * and SOULSTONE's floor holds even at 3 life or below. */ +export const CURRENT_RULES_REV = 8; export interface GameConfig { playerIds: PlayerId[]; @@ -603,7 +605,7 @@ export type GameEvent = | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number; soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] } - | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" } + | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" } | { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number } | { type: "stunned"; player: PlayerId; turnsLost: number } | { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number } @@ -3010,7 +3012,20 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId): if (!gameLos(state, imp.position, p.position)) continue; imp.scorchedThisTurn.push(p.id); events.push({ type: "impScorches", creatureId: imp.id, player: p.id }); - applyDamage(state, events, p, 2, "fire imp", null); + // Rev 8, per the FAQ: every imp attack is technically a spell — the + // scorch opens a counteraction window and burns as MAGICAL fire + // (soulstone's floor holds). One victim's window at a time; the + // sweep finds the rest on its next pass. Older games keep the + // instant kindless burn their ledgers recorded. + if ((state.config.deckRev ?? 1) >= 8) { + if (!state.stack) { + openCreatureStack(state, imp, p, 2, undefined, "spell"); + break; + } + imp.scorchedThisTurn.pop(); // window busy: burn on a later sweep + } else { + applyDamage(state, events, p, 2, "fire imp", null); + } } } checkVictory(state, events); @@ -3168,6 +3183,7 @@ function openCreatureStack( victim: PlayerState, damage: number, touch?: "wraith" | "claw", + kind: "physical" | "spell" = "physical", ): void { state.stack = { attackerId: creature.controllerId, @@ -3178,7 +3194,7 @@ function openCreatureStack( extendFactor: 1, powerAttackPoints: 0, params: { damage }, - kind: "physical", + kind, counters: [], waitingOn: victim.id, creatureId: creature.id, @@ -6213,10 +6229,24 @@ function applyDamage( soaks.push({ what: "bloodstone", amount: 1 }); } // SOULSTONE: "Last three points ... can only be lost to physical damage." - if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) { - const clamped = Math.min(amount, target.life - 3); - if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped }); - amount = clamped; + // Rev 8: the floor holds even from below — a bearer already at 3 or + // less takes NOTHING from spells. Earlier revisions clamped only from + // above (a bearer at 1 could be burned to death), and their ledgers + // replay that flaw. + if (damageKind === "spell" && displays(target, "soulstone")) { + if ((state.config.deckRev ?? 1) >= 8) { + const clamped = Math.max(0, Math.min(amount, target.life - 3)); + if (clamped === 0) { + events.push({ type: "damageImmune", player: target.id, source, because: "soulstone" }); + return; + } + if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped }); + amount = clamped; + } else if (target.life > 3) { + const clamped = Math.min(amount, target.life - 3); + if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped }); + amount = clamped; + } } target.life -= amount; diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 2754c77..dc6406f 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -146,29 +146,37 @@ describe("monsters", () => { expect(bitten.hand.length).toBe(6); }); + /** Rev 8: scorches open counteraction windows — wave them through. */ + function drain(state: GameState): GameState { + while (state.stack) { + state = must(state, state.stack.waitingOn, { type: "pass" }); + } + return state; + } + it("the fire imp scorches on sight and dies only to water", () => { let { state } = newGame(); state = toRound2(state); const me = activePlayer(state).id; const r = summon(state, me, "fire-imp"); - state = r.state; + state = drain(r.state); const imp = state.creatures[0]!; // Fireball cannot destroy it... - state = must(state, me, { type: "endTurn", draw: 0 }); + state = drain(must(state, me, { type: "endTurn", draw: 0 })); const enemy = state.players.find((p) => p.id !== me)!; // (enemy may have been scorched at turn start if in LOS — note life) const enemyNow = state.players.find((p) => p.id !== me)!; enemyNow.position = { ...imp.position }; // stand at the imp for clear sight const fb = giveCard(state, enemy.id, "fireball", "F", 0); - state = must(state, enemy.id, { + state = drain(must(state, enemy.id, { type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id }, - }); + })); expect(state.creatures.length).toBe(1); // ...but a waterbolt douses it instantly. - state = must(state, enemy.id, { type: "endTurn", draw: 0 }); - state = must(state, me, { type: "endTurn", draw: 0 }); + state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 })); + state = drain(must(state, me, { type: "endTurn", draw: 0 })); state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position }; const wb = giveCard(state, enemy.id, "waterbolt", "W", 0); state = must(state, enemy.id, { @@ -787,3 +795,59 @@ describe("creatures and the dimensional warp", () => { if (!r.ok) expect(r.error).toContain("stone"); }); }); + +describe("the imp's fire is a spell (rev 8)", () => { + function impRig() { + let { state } = createGame({ playerIds: ["imp-owner", "mark"], seed: 42, sets: ["basic", "expansion1"] }); + state = toRound2(state); + while (activePlayer(state).id !== "imp-owner") { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + return state; + } + + it("scorch opens a counteraction window and FULL SHIELD stops it", () => { + let state = impRig(); + const owner = state.players.find((p) => p.id === "imp-owner")!; + const mark = state.players.find((p) => p.id === "mark")!; + mark.position = { ...owner.position }; + const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0); + const spot = emptyNeighborCell(state, owner.position); + let r = applyCommand(state, "imp-owner", { + type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); + // The scorch rides the stack now: a counteraction window, spell-kind. + expect(state.stack?.creatureId).toBeTruthy(); + expect(state.stack?.kind).toBe("spell"); + giveCard(state, "mark", "full-shield", "FS", 0); + state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" }); + while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + expect(state.players.find((p) => p.id === "mark")!.life).toBe(15); + }); + + it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => { + let state = impRig(); + const owner = state.players.find((p) => p.id === "imp-owner")!; + const mark = state.players.find((p) => p.id === "mark")!; + mark.position = { ...owner.position }; + mark.life = 2; + mark.displayed.push("SS"); + mark.hand[0] = { instanceId: "SS", cardId: "soulstone" }; + const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 1); + const spot = emptyNeighborCell(state, owner.position); + let r = applyCommand(state, "imp-owner", { + type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell }, + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 }); + expect(state.stack?.kind).toBe("spell"); + while (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + const after = state.players.find((p) => p.id === "mark")!; + expect(after.life).toBe(2); + expect(after.alive).toBe(true); + }); +});