Files
systemic-game-design-course/course/03-death-step/reference/rust/src/harness.rs
T
Lucas WintherandClaude Opus 5.5 8a4eba1948 feat: module 03 death step
Deferred death: damage and destroy only mark minions; the Death Creation
Step removes them when the outermost Phase ends, and Death Phases resolve
Deathrattles in order of play until no new deaths occur. Deathrattle
summons use the board position the Death Event remembered. Harness adds
destroy(target). Expansion Drop: Twisting Nether as content only.

test_02_mortally_wounded_still_triggers no longer asserts the Acolyte is
still on the board: from Module 3 on it leaves at the end of the Sequence,
and verify-references runs Module 2's tests against the Module 3 reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:42:12 +01:00

150 lines
4.8 KiB
Rust

//! Test harness: the thin adapter the course tests call. It translates each
//! call into engine operations and holds no rules of its own.
use crate::content;
use crate::engine::{run_sequence, Action, Event, World};
pub use crate::engine::Zone;
pub type Id = u32;
pub type Player = u8;
#[derive(Debug, Default, Clone)]
pub struct GameOpts {
pub decks: Option<[Vec<String>; 2]>,
}
/// The events the tests read from `log()` (see course/harness.md).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameEvent {
Summoned { id: Id },
Damaged { target: Id, amount: i32 },
Triggered { source: Id, trigger: String },
Destroyed { id: Id },
DeathPhase { round: u32 },
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
impl From<&Event> for GameEvent {
fn from(event: &Event) -> GameEvent {
match event.clone() {
Event::Summoned { id } => GameEvent::Summoned { id },
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
Event::Triggered { source, on } => GameEvent::Triggered {
source,
trigger: on.label().to_string(),
},
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
Event::SpellCast { card } => GameEvent::SpellCast { card },
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
}
}
}
pub struct Game {
world: World,
}
pub fn new_game(opts: GameOpts) -> Game {
let mut world = World::new(content::library());
if let Some(decks) = opts.decks {
for (player, deck) in decks.iter().enumerate() {
for card in deck {
world.create(card, player as Player, Zone::Deck);
}
}
}
Game { world }
}
impl Game {
/// Put `card` at the right end of `player`'s board.
pub fn summon(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Board)
}
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Hand)
}
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Deck)
}
/// Play a card from hand. This is the Module 1 Expansion Drop: see
/// course/01-handles/expansion/README.md.
pub fn play(&mut self, _hand_id: Id) {
todo!("Module 1 Expansion Drop")
}
pub fn zone_of(&self, id: Id) -> Zone {
self.world.zone_of(id)
}
/// Board ids, left to right.
pub fn board(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Board).to_vec()
}
pub fn hand(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Hand).to_vec()
}
pub fn order_of_play(&self, id: Id) -> u32 {
self.world
.order_of_play(id)
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
}
pub fn name_of(&self, id: Id) -> String {
self.world.card_name(id).to_string()
}
// ---- Module 2 ----
pub fn hero(&self, player: Player) -> Id {
self.world.hero(player)
}
/// Deal damage to one character, as one Sequence.
pub fn deal_damage(&mut self, target: Id, amount: i32) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Damage { targets, amount });
}
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
let card = card.to_string();
run_sequence(
&mut self.world,
Action::Cast {
card,
player,
target,
},
);
}
pub fn health(&self, id: Id) -> i32 {
self.world.health(id)
}
pub fn attack(&self, id: Id) -> i32 {
self.world.attack(id)
}
pub fn armor(&self, player: Player) -> i32 {
self.world.armor(player)
}
pub fn log(&self) -> Vec<GameEvent> {
self.world.log().iter().map(GameEvent::from).collect()
}
pub fn clear_log(&mut self) {
self.world.clear_log();
}
// ---- Module 3 ----
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
pub fn destroy(&mut self, target: Id) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Destroy { targets });
}
}