A Deathrattle summon used the dying minion's board index from before the Death Creation Step removed anything. That index is stale when a minion to its left dies in the same step, so the token landed one place too far right. Each Death Event now records `before`: the nearest minion to the right that is not dying in the same step. The position handed to the effect context is resolved when the trigger resolves: the current index of `before`, or the right end. Applied to the Rust and TS references of Modules 3-10. Module 3 gains two tests, documented in scenarios.md: test_03_summon_position_after_simultaneous_deaths (fails before the fix) and test_03_simultaneous_summons_keep_board_order. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
// Module 3: Deferred death. Each test matches a heading in scenarios.md.
|
|
import { expect, test } from "vitest";
|
|
import { newGame } from "../src/harness";
|
|
|
|
const fillers = (n: number): string[] => Array(n).fill("Test Filler");
|
|
|
|
test("test_03_removed_only_after_sequence", () => {
|
|
const g = newGame({ decks: [fillers(5), []] });
|
|
const h = g.summon("Loot Hoarder", 0);
|
|
const s = g.summon("Armorsmith", 0);
|
|
g.clearLog();
|
|
|
|
g.cast("Whirlwind", 0);
|
|
|
|
const hand = g.hand(0);
|
|
expect(hand).toHaveLength(1);
|
|
const c = hand[0];
|
|
expect(g.log()).toEqual([
|
|
{ type: "SpellCast", card: "Whirlwind" },
|
|
{ type: "Damaged", target: h, amount: 1 },
|
|
{ type: "Damaged", target: s, amount: 1 },
|
|
{ type: "Triggered", source: s, trigger: "damaged" },
|
|
{ type: "ArmorGained", player: 0, amount: 1 },
|
|
{ type: "Triggered", source: s, trigger: "damaged" },
|
|
{ type: "ArmorGained", player: 0, amount: 1 },
|
|
{ type: "Destroyed", id: h },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: h, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 0, id: c },
|
|
]);
|
|
expect(g.zoneOf(h)).toBe("graveyard");
|
|
expect(g.board(0)).toEqual([s]);
|
|
expect(g.armor(0)).toBe(2);
|
|
});
|
|
|
|
test("test_03_chained_death_phases", () => {
|
|
const g = newGame();
|
|
const golem = g.summon("Harvest Golem", 0);
|
|
const ghoul = g.summon("Unstable Ghoul", 0);
|
|
const r = g.summon("Test Reactor", 0);
|
|
g.dealDamage(golem, 2);
|
|
g.clearLog();
|
|
|
|
g.dealDamage(ghoul, 3);
|
|
|
|
const d = g.board(0)[0];
|
|
expect(g.log()).toEqual([
|
|
{ type: "Damaged", target: ghoul, amount: 3 },
|
|
{ type: "Destroyed", id: ghoul },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: ghoul, trigger: "deathrattle" },
|
|
{ type: "Damaged", target: golem, amount: 1 },
|
|
{ type: "Damaged", target: r, amount: 1 },
|
|
{ type: "Destroyed", id: golem },
|
|
{ type: "DeathPhase", round: 2 },
|
|
{ type: "Triggered", source: golem, trigger: "deathrattle" },
|
|
{ type: "Summoned", id: d },
|
|
]);
|
|
expect(g.nameOf(d)).toBe("Damaged Golem");
|
|
expect(g.board(0)).toEqual([d, r]);
|
|
expect(g.zoneOf(ghoul)).toBe("graveyard");
|
|
expect(g.zoneOf(golem)).toBe("graveyard");
|
|
expect(g.health(r)).toBe(4);
|
|
});
|
|
|
|
test("test_03_simultaneous_removal", () => {
|
|
const g = newGame({ decks: [fillers(5), []] });
|
|
const h1 = g.summon("Loot Hoarder", 0);
|
|
const h2 = g.summon("Loot Hoarder", 0);
|
|
g.clearLog();
|
|
|
|
g.cast("Whirlwind", 0);
|
|
|
|
const hand = g.hand(0);
|
|
expect(hand).toHaveLength(2);
|
|
const [c1, c2] = hand;
|
|
expect(g.log()).toEqual([
|
|
{ type: "SpellCast", card: "Whirlwind" },
|
|
{ type: "Damaged", target: h1, amount: 1 },
|
|
{ type: "Damaged", target: h2, amount: 1 },
|
|
{ type: "Destroyed", id: h1 },
|
|
{ type: "Destroyed", id: h2 },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: h1, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 0, id: c1 },
|
|
{ type: "Triggered", source: h2, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 0, id: c2 },
|
|
]);
|
|
expect(g.zoneOf(h1)).toBe("graveyard");
|
|
expect(g.zoneOf(h2)).toBe("graveyard");
|
|
expect(g.board(0)).toEqual([]);
|
|
});
|
|
|
|
test("test_03_destroy_marks_pending", () => {
|
|
const g = newGame();
|
|
const golem = g.summon("Harvest Golem", 0);
|
|
g.clearLog();
|
|
|
|
g.destroy(golem);
|
|
|
|
const board = g.board(0);
|
|
expect(board).toHaveLength(1);
|
|
const d = board[0];
|
|
expect(g.log()).toEqual([
|
|
{ type: "Destroyed", id: golem },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: golem, trigger: "deathrattle" },
|
|
{ type: "Summoned", id: d },
|
|
]);
|
|
expect(g.health(golem)).toBe(3);
|
|
expect(g.zoneOf(golem)).toBe("graveyard");
|
|
expect(g.nameOf(d)).toBe("Damaged Golem");
|
|
});
|
|
|
|
test("test_03_summon_position_after_simultaneous_deaths", () => {
|
|
const g = newGame({ decks: [fillers(5), []] });
|
|
const h = g.summon("Loot Hoarder", 0);
|
|
const golem = g.summon("Harvest Golem", 0);
|
|
const r = g.summon("Test Reactor", 0);
|
|
g.dealDamage(golem, 2);
|
|
g.clearLog();
|
|
|
|
g.cast("Whirlwind", 0);
|
|
|
|
const hand = g.hand(0);
|
|
expect(hand).toHaveLength(1);
|
|
const c = hand[0];
|
|
const summoned = g.log().flatMap((e) => (e.type === "Summoned" ? [e.id] : []));
|
|
expect(summoned).toHaveLength(1);
|
|
const d = summoned[0];
|
|
expect(g.log()).toEqual([
|
|
{ type: "SpellCast", card: "Whirlwind" },
|
|
{ type: "Damaged", target: h, amount: 1 },
|
|
{ type: "Damaged", target: golem, amount: 1 },
|
|
{ type: "Damaged", target: r, amount: 1 },
|
|
{ type: "Destroyed", id: h },
|
|
{ type: "Destroyed", id: golem },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: h, trigger: "deathrattle" },
|
|
{ type: "Drew", player: 0, id: c },
|
|
{ type: "Triggered", source: golem, trigger: "deathrattle" },
|
|
{ type: "Summoned", id: d },
|
|
]);
|
|
expect(g.nameOf(d)).toBe("Damaged Golem");
|
|
expect(g.board(0)).toEqual([d, r]);
|
|
expect(g.zoneOf(h)).toBe("graveyard");
|
|
expect(g.zoneOf(golem)).toBe("graveyard");
|
|
});
|
|
|
|
test("test_03_simultaneous_summons_keep_board_order", () => {
|
|
const g = newGame();
|
|
const a = g.summon("Harvest Golem", 0);
|
|
const b = g.summon("Harvest Golem", 0);
|
|
const r = g.summon("Test Reactor", 0);
|
|
g.dealDamage(a, 2);
|
|
g.dealDamage(b, 2);
|
|
g.clearLog();
|
|
|
|
g.cast("Whirlwind", 0);
|
|
|
|
const summoned = g.log().flatMap((e) => (e.type === "Summoned" ? [e.id] : []));
|
|
expect(summoned).toHaveLength(2);
|
|
const [da, db] = summoned;
|
|
expect(g.log()).toEqual([
|
|
{ type: "SpellCast", card: "Whirlwind" },
|
|
{ type: "Damaged", target: a, amount: 1 },
|
|
{ type: "Damaged", target: b, amount: 1 },
|
|
{ type: "Damaged", target: r, amount: 1 },
|
|
{ type: "Destroyed", id: a },
|
|
{ type: "Destroyed", id: b },
|
|
{ type: "DeathPhase", round: 1 },
|
|
{ type: "Triggered", source: a, trigger: "deathrattle" },
|
|
{ type: "Summoned", id: da },
|
|
{ type: "Triggered", source: b, trigger: "deathrattle" },
|
|
{ type: "Summoned", id: db },
|
|
]);
|
|
expect(g.nameOf(da)).toBe("Damaged Golem");
|
|
expect(g.nameOf(db)).toBe("Damaged Golem");
|
|
expect(g.board(0)).toEqual([da, db, r]);
|
|
expect(g.zoneOf(a)).toBe("graveyard");
|
|
expect(g.zoneOf(b)).toBe("graveyard");
|
|
});
|