fix(reference): Death Events remember a right-hand neighbour, not a board index

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>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 09:15:48 +01:00
1 parent 638549161f
commit 429137a6ef
79 files changed
+808 -236

No files matched your search

@@ -22,6 +22,16 @@ fn whirlwind() -> GameEvent {
}
}
/// The ids of the `Summoned` events in `log`, in order.
fn summoned_ids(log: &[GameEvent]) -> Vec<Id> {
log.iter()
.filter_map(|e| match e {
GameEvent::Summoned { id } => Some(*id),
_ => None,
})
.collect()
}
#[test]
fn test_03_removed_only_after_sequence() {
let mut g = new_game(GameOpts {
@@ -155,3 +165,82 @@ fn test_03_destroy_marks_pending() {
assert_eq!(g.zone_of(golem), Zone::Graveyard);
assert_eq!(g.name_of(d), "Damaged Golem");
}
#[test]
fn test_03_summon_position_after_simultaneous_deaths() {
let mut g = new_game(GameOpts {
decks: Some([fillers(5), vec![]]),
});
let h = g.summon("Loot Hoarder", 0);
let golem = g.summon("Harvest Golem", 0);
let r = g.summon("Test Reactor", 0);
g.deal_damage(golem, 2);
g.clear_log();
g.cast("Whirlwind", 0, None);
let hand = g.hand(0);
assert_eq!(hand.len(), 1);
let c = hand[0];
let summoned = summoned_ids(&g.log());
assert_eq!(summoned.len(), 1);
let d = summoned[0];
assert_eq!(
g.log(),
vec![
whirlwind(),
damaged(h, 1),
damaged(golem, 1),
damaged(r, 1),
GameEvent::Destroyed { id: h },
GameEvent::Destroyed { id: golem },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c },
triggered(golem, "deathrattle"),
GameEvent::Summoned { id: d },
]
);
assert_eq!(g.name_of(d), "Damaged Golem");
assert_eq!(g.board(0), vec![d, r]);
assert_eq!(g.zone_of(h), Zone::Graveyard);
assert_eq!(g.zone_of(golem), Zone::Graveyard);
}
#[test]
fn test_03_simultaneous_summons_keep_board_order() {
let mut g = new_game(GameOpts::default());
let a = g.summon("Harvest Golem", 0);
let b = g.summon("Harvest Golem", 0);
let r = g.summon("Test Reactor", 0);
g.deal_damage(a, 2);
g.deal_damage(b, 2);
g.clear_log();
g.cast("Whirlwind", 0, None);
let summoned = summoned_ids(&g.log());
assert_eq!(summoned.len(), 2);
let (da, db) = (summoned[0], summoned[1]);
assert_eq!(
g.log(),
vec![
whirlwind(),
damaged(a, 1),
damaged(b, 1),
damaged(r, 1),
GameEvent::Destroyed { id: a },
GameEvent::Destroyed { id: b },
GameEvent::DeathPhase { round: 1 },
triggered(a, "deathrattle"),
GameEvent::Summoned { id: da },
triggered(b, "deathrattle"),
GameEvent::Summoned { id: db },
]
);
assert_eq!(g.name_of(da), "Damaged Golem");
assert_eq!(g.name_of(db), "Damaged Golem");
assert_eq!(g.board(0), vec![da, db, r]);
assert_eq!(g.zone_of(a), Zone::Graveyard);
assert_eq!(g.zone_of(b), Zone::Graveyard);
}
@@ -111,3 +111,72 @@ test("test_03_destroy_marks_pending", () => {
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");
});