Files
systemic-game-design-course/course/03-death-step/tests/rust/test_03.rs
T
Lucas WintherandClaude Fable 5.2 429137a6ef 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>
2026-11-11 09:15:48 +01:00

247 lines
6.8 KiB
Rust

// Module 3: Deferred death. Each test matches a heading in scenarios.md.
use engine::harness::*;
fn fillers(n: usize) -> Vec<String> {
vec!["Test Filler".to_string(); n]
}
fn damaged(target: Id, amount: i32) -> GameEvent {
GameEvent::Damaged { target, amount }
}
fn triggered(source: Id, trigger: &str) -> GameEvent {
GameEvent::Triggered {
source,
trigger: trigger.to_string(),
}
}
fn whirlwind() -> GameEvent {
GameEvent::SpellCast {
card: "Whirlwind".to_string(),
}
}
/// 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 {
decks: Some([fillers(5), vec![]]),
});
let h = g.summon("Loot Hoarder", 0);
let s = g.summon("Armorsmith", 0);
g.clear_log();
g.cast("Whirlwind", 0, None);
let hand = g.hand(0);
assert_eq!(hand.len(), 1);
let c = hand[0];
assert_eq!(
g.log(),
vec![
whirlwind(),
damaged(h, 1),
damaged(s, 1),
triggered(s, "damaged"),
GameEvent::ArmorGained {
player: 0,
amount: 1
},
triggered(s, "damaged"),
GameEvent::ArmorGained {
player: 0,
amount: 1
},
GameEvent::Destroyed { id: h },
GameEvent::DeathPhase { round: 1 },
triggered(h, "deathrattle"),
GameEvent::Drew { player: 0, id: c },
]
);
assert_eq!(g.zone_of(h), Zone::Graveyard);
assert_eq!(g.board(0), vec![s]);
assert_eq!(g.armor(0), 2);
}
#[test]
fn test_03_chained_death_phases() {
let mut g = new_game(GameOpts::default());
let golem = g.summon("Harvest Golem", 0);
let ghoul = g.summon("Unstable Ghoul", 0);
let r = g.summon("Test Reactor", 0);
g.deal_damage(golem, 2);
g.clear_log();
g.deal_damage(ghoul, 3);
let d = g.board(0)[0];
assert_eq!(
g.log(),
vec![
damaged(ghoul, 3),
GameEvent::Destroyed { id: ghoul },
GameEvent::DeathPhase { round: 1 },
triggered(ghoul, "deathrattle"),
damaged(golem, 1),
damaged(r, 1),
GameEvent::Destroyed { id: golem },
GameEvent::DeathPhase { round: 2 },
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(ghoul), Zone::Graveyard);
assert_eq!(g.zone_of(golem), Zone::Graveyard);
assert_eq!(g.health(r), 4);
}
#[test]
fn test_03_simultaneous_removal() {
let mut g = new_game(GameOpts {
decks: Some([fillers(5), vec![]]),
});
let h1 = g.summon("Loot Hoarder", 0);
let h2 = g.summon("Loot Hoarder", 0);
g.clear_log();
g.cast("Whirlwind", 0, None);
let hand = g.hand(0);
assert_eq!(hand.len(), 2);
let (c1, c2) = (hand[0], hand[1]);
assert_eq!(
g.log(),
vec![
whirlwind(),
damaged(h1, 1),
damaged(h2, 1),
GameEvent::Destroyed { id: h1 },
GameEvent::Destroyed { id: h2 },
GameEvent::DeathPhase { round: 1 },
triggered(h1, "deathrattle"),
GameEvent::Drew { player: 0, id: c1 },
triggered(h2, "deathrattle"),
GameEvent::Drew { player: 0, id: c2 },
]
);
assert_eq!(g.zone_of(h1), Zone::Graveyard);
assert_eq!(g.zone_of(h2), Zone::Graveyard);
assert_eq!(g.board(0), Vec::<Id>::new());
}
#[test]
fn test_03_destroy_marks_pending() {
let mut g = new_game(GameOpts::default());
let golem = g.summon("Harvest Golem", 0);
g.clear_log();
g.destroy(golem);
let board = g.board(0);
assert_eq!(board.len(), 1);
let d = board[0];
assert_eq!(
g.log(),
vec![
GameEvent::Destroyed { id: golem },
GameEvent::DeathPhase { round: 1 },
triggered(golem, "deathrattle"),
GameEvent::Summoned { id: d },
]
);
assert_eq!(g.health(golem), 3);
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);
}