The imp's fire is a spell; the soulstone floor holds (rules rev 8)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
5dde7b4c21
commit
019deedd85
@@ -236,8 +236,10 @@ export interface CastParams {
|
|||||||
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would
|
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would
|
||||||
* be an attack") instead of resolving instantly.
|
* be an attack") instead of resolving instantly.
|
||||||
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster
|
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster
|
||||||
* carries it, sharing a square bites both directions, no counteraction. */
|
* carries it, sharing a square bites both directions, no counteraction.
|
||||||
export const CURRENT_RULES_REV = 7;
|
* 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 {
|
export interface GameConfig {
|
||||||
playerIds: PlayerId[];
|
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: "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;
|
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
|
||||||
soaks?: { what: "bloodstone" | "soulstone"; amount: 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: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number }
|
||||||
| { type: "stunned"; player: PlayerId; turnsLost: number }
|
| { type: "stunned"; player: PlayerId; turnsLost: number }
|
||||||
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: 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;
|
if (!gameLos(state, imp.position, p.position)) continue;
|
||||||
imp.scorchedThisTurn.push(p.id);
|
imp.scorchedThisTurn.push(p.id);
|
||||||
events.push({ type: "impScorches", creatureId: imp.id, player: 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);
|
checkVictory(state, events);
|
||||||
@@ -3168,6 +3183,7 @@ function openCreatureStack(
|
|||||||
victim: PlayerState,
|
victim: PlayerState,
|
||||||
damage: number,
|
damage: number,
|
||||||
touch?: "wraith" | "claw",
|
touch?: "wraith" | "claw",
|
||||||
|
kind: "physical" | "spell" = "physical",
|
||||||
): void {
|
): void {
|
||||||
state.stack = {
|
state.stack = {
|
||||||
attackerId: creature.controllerId,
|
attackerId: creature.controllerId,
|
||||||
@@ -3178,7 +3194,7 @@ function openCreatureStack(
|
|||||||
extendFactor: 1,
|
extendFactor: 1,
|
||||||
powerAttackPoints: 0,
|
powerAttackPoints: 0,
|
||||||
params: { damage },
|
params: { damage },
|
||||||
kind: "physical",
|
kind,
|
||||||
counters: [],
|
counters: [],
|
||||||
waitingOn: victim.id,
|
waitingOn: victim.id,
|
||||||
creatureId: creature.id,
|
creatureId: creature.id,
|
||||||
@@ -6213,10 +6229,24 @@ function applyDamage(
|
|||||||
soaks.push({ what: "bloodstone", amount: 1 });
|
soaks.push({ what: "bloodstone", amount: 1 });
|
||||||
}
|
}
|
||||||
// SOULSTONE: "Last three points ... can only be lost to physical damage."
|
// SOULSTONE: "Last three points ... can only be lost to physical damage."
|
||||||
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) {
|
// Rev 8: the floor holds even from below — a bearer already at 3 or
|
||||||
const clamped = Math.min(amount, target.life - 3);
|
// less takes NOTHING from spells. Earlier revisions clamped only from
|
||||||
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
|
// above (a bearer at 1 could be burned to death), and their ledgers
|
||||||
amount = clamped;
|
// 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;
|
target.life -= amount;
|
||||||
|
|||||||
@@ -146,29 +146,37 @@ describe("monsters", () => {
|
|||||||
expect(bitten.hand.length).toBe(6);
|
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", () => {
|
it("the fire imp scorches on sight and dies only to water", () => {
|
||||||
let { state } = newGame();
|
let { state } = newGame();
|
||||||
state = toRound2(state);
|
state = toRound2(state);
|
||||||
const me = activePlayer(state).id;
|
const me = activePlayer(state).id;
|
||||||
const r = summon(state, me, "fire-imp");
|
const r = summon(state, me, "fire-imp");
|
||||||
state = r.state;
|
state = drain(r.state);
|
||||||
const imp = state.creatures[0]!;
|
const imp = state.creatures[0]!;
|
||||||
|
|
||||||
// Fireball cannot destroy it...
|
// 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)!;
|
const enemy = state.players.find((p) => p.id !== me)!;
|
||||||
// (enemy may have been scorched at turn start if in LOS — note life)
|
// (enemy may have been scorched at turn start if in LOS — note life)
|
||||||
const enemyNow = state.players.find((p) => p.id !== me)!;
|
const enemyNow = state.players.find((p) => p.id !== me)!;
|
||||||
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
||||||
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
|
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 },
|
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
||||||
});
|
}));
|
||||||
expect(state.creatures.length).toBe(1);
|
expect(state.creatures.length).toBe(1);
|
||||||
|
|
||||||
// ...but a waterbolt douses it instantly.
|
// ...but a waterbolt douses it instantly.
|
||||||
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 }));
|
||||||
state = must(state, me, { 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 };
|
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
|
||||||
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
||||||
state = must(state, enemy.id, {
|
state = must(state, enemy.id, {
|
||||||
@@ -787,3 +795,59 @@ describe("creatures and the dimensional warp", () => {
|
|||||||
if (!r.ok) expect(r.error).toContain("stone");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user