Files
systemic-game-design-course/course/05-assemble/tests/rust/test_05.rs
T
Lucas WintherandClaude Opus 5.5 e04dc1a11f feat: module 05 assemble
Cards become JSON data in course/cards (one file per card, validated
against card.schema.json and the catalog); the M5 references load them
via registerCards and interpret effects, replacing content closures.
Adds assemble(base, donor, half), the assemble/buffHand effects with an
injected chooser, Fleshshape as the data-only Expansion Drop, and stages
course/cards in verify-references and start-module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:17:21 +01:00

309 lines
8.5 KiB
Rust

// Module 5: Behavior as data and Assemble. Each test matches a heading in scenarios.md.
use std::cell::RefCell;
use std::rc::Rc;
use engine::harness::*;
/// Every file in the workspace's `cards/` directory, in file-name order: (raw JSON, parsed).
fn card_files() -> Vec<(serde_json::Value, CardDef)> {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/cards");
let mut paths: Vec<_> = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read {dir}: {e}"))
.map(|entry| entry.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
paths.sort();
paths
.iter()
.map(|p| {
let text = std::fs::read_to_string(p).unwrap();
let raw: serde_json::Value = serde_json::from_str(&text).unwrap();
let def: CardDef =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", p.display()));
(raw, def)
})
.collect()
}
/// Names of the minion files with a trigger clause listening for `on`, sorted.
fn minions_with_trigger(on: &str) -> Vec<String> {
let mut names: Vec<String> = card_files()
.into_iter()
.filter(|(raw, _)| raw["type"] == "minion")
.filter(|(raw, _)| {
raw["text"]
.as_array()
.unwrap()
.iter()
.any(|c| c["kind"] == "trigger" && c["on"] == on)
})
.map(|(raw, _)| raw["name"].as_str().unwrap().to_string())
.collect();
names.sort();
names
}
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(),
}
}
#[test]
fn test_05_regression_data_cards() {
let files = card_files();
let names: Vec<&str> = files
.iter()
.map(|(raw, _)| raw["name"].as_str().unwrap())
.collect();
for used in [
"Armorsmith",
"Acolyte of Pain",
"Loot Hoarder",
"Harvest Golem",
"Damaged Golem",
"Unstable Ghoul",
"Baron Rivendare",
"Novice Engineer",
"Whirlwind",
"Twisting Nether",
"Test Echo",
"Test Reactor",
"Test Filler",
] {
assert!(names.contains(&used), "cards/ has no file for {used}");
}
let mut g = new_game(GameOpts::default());
g.register_cards(files.iter().map(|(_, def)| def.clone()).collect());
for (raw, _) in files.iter().filter(|(raw, _)| raw["type"] == "minion") {
let name = raw["name"].as_str().unwrap();
let id = g.summon(name, 0);
assert_eq!(g.name_of(id), name);
assert_eq!(
g.attack(id) as i64,
raw["attack"].as_i64().unwrap(),
"{name}"
);
assert_eq!(
g.health(id) as i64,
raw["health"].as_i64().unwrap(),
"{name}"
);
assert_eq!(g.cost(id) as i64, raw["cost"].as_i64().unwrap(), "{name}");
}
}
#[test]
fn test_05_assemble_bottom_half() {
let mut g = new_game(GameOpts::default());
let name = g
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
.unwrap();
assert_eq!(name, "Harvest Golem (Assembled)");
let b = g.summon("Baron Rivendare", 0);
let f = g.summon("Test Filler", 0);
let s = g.summon("Test Sponge", 0);
let a = g.summon(&name, 0);
let (h0, h1) = (g.hero(0), g.hero(1));
g.clear_log();
g.destroy(a);
let activation = vec![
triggered(a, "deathrattle"),
damaged(h0, 1),
damaged(h1, 1),
damaged(b, 1),
damaged(f, 1),
damaged(s, 1),
];
let mut expected = vec![
GameEvent::Destroyed { id: a },
GameEvent::DeathPhase { round: 1 },
];
expected.extend(activation.clone());
expected.extend(activation);
expected.push(GameEvent::Destroyed { id: f });
expected.push(GameEvent::DeathPhase { round: 2 });
assert_eq!(g.log(), expected);
assert_eq!(g.health(h0), 28);
assert_eq!(g.health(h1), 28);
assert_eq!(g.health(b), 5);
assert_eq!(g.health(s), 1);
assert_eq!(g.zone_of(f), Zone::Graveyard);
assert_eq!(g.board(0), vec![b, s]);
}
#[test]
fn test_05_assembled_keeps_base_stats() {
let mut g = new_game(GameOpts::default());
let name = g
.assemble("Harvest Golem", "Dread Infernal", Half::Bottom)
.unwrap();
let a = g.add_to_hand(&name, 0);
assert_eq!(g.name_of(a), name);
assert!(name.contains("Harvest Golem"));
assert_eq!(g.cost(a), 3);
assert_eq!(g.attack(a), 2);
assert_eq!(g.health(a), 3);
}
#[test]
fn test_05_assemble_must_change_text() {
let mut g = new_game(GameOpts::default());
let bottom = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Bottom);
let top = g.assemble("Loot Hoarder", "Loot Hoarder", Half::Top);
assert!(bottom.is_err(), "{bottom:?}");
assert!(top.is_err(), "{top:?}");
}
#[test]
fn test_05_top_half_swap() {
let mut g = new_game(GameOpts {
decks: Some([fillers(5), vec![]]),
});
let name = g
.assemble("Loot Hoarder", "Novice Engineer", Half::Top)
.unwrap();
let e = g.add_to_hand(&name, 0);
g.clear_log();
g.play(e);
let hand = g.hand(0);
assert_eq!(hand.len(), 1);
let c = hand[0];
assert_eq!(
g.log(),
vec![
GameEvent::Summoned { id: e },
triggered(e, "battlecry"),
GameEvent::Drew { player: 0, id: c },
]
);
assert_eq!(g.attack(e), 2);
assert_eq!(g.health(e), 1);
g.clear_log();
g.destroy(e);
assert_eq!(
g.log(),
vec![
GameEvent::Destroyed { id: e },
GameEvent::DeathPhase { round: 1 },
]
);
assert_eq!(g.hand(0).len(), 1);
}
#[test]
fn test_05_assemble_effect_with_chooser() {
let mut g = new_game(GameOpts::default());
let t = g.add_to_hand("Test Filler", 0);
let calls: Rc<RefCell<Vec<(String, Vec<String>)>>> = Rc::default();
let record = Rc::clone(&calls);
g.set_chooser(move |kind: &str, options: &[String]| {
record
.borrow_mut()
.push((kind.to_string(), options.to_vec()));
match kind {
"base" => "Novice Engineer",
"donor" => "Harvest Golem",
_ => "bottom",
}
.to_string()
});
g.clear_log();
g.cast("Test Workshop", 0, None);
let calls = calls.borrow().clone();
let kinds: Vec<&str> = calls.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(kinds, vec!["base", "donor", "half"]);
assert_eq!(calls[0].1, minions_with_trigger("battlecry"));
assert!(calls[1].1.contains(&"Harvest Golem".to_string()));
assert!(!calls[1].1.contains(&"Novice Engineer".to_string()));
assert_eq!(calls[2].1, vec!["top".to_string(), "bottom".to_string()]);
let hand = g.hand(0);
assert_eq!(hand.len(), 2);
assert_eq!(hand[0], t);
let a = hand[1];
assert_eq!(g.name_of(a), "Novice Engineer (Assembled)");
assert_eq!(g.cost(a), 2);
assert_eq!(g.attack(a), 2);
assert_eq!(g.health(a), 2);
assert_eq!(
g.log(),
vec![
GameEvent::SpellCast {
card: "Test Workshop".to_string()
},
GameEvent::StatsChanged {
id: t,
attack: 2,
health: 2
},
GameEvent::StatsChanged {
id: a,
attack: 2,
health: 2
},
]
);
g.clear_log();
g.play(a);
let board = g.board(0);
assert_eq!(board.len(), 2);
assert_eq!(board[0], a);
let d = board[1];
assert_eq!(g.name_of(d), "Damaged Golem");
assert_eq!(
g.log(),
vec![
GameEvent::Summoned { id: a },
triggered(a, "battlecry"),
GameEvent::Summoned { id: d },
]
);
}
#[test]
fn test_05_additive_multipliers_stack() {
let mut g = new_game(GameOpts {
decks: Some([fillers(5), vec![]]),
});
g.summon("Test Chorus", 0);
g.summon("Test Chorus", 0);
let h = g.summon("Loot Hoarder", 0);
g.clear_log();
g.deal_damage(h, 1);
assert_eq!(g.hand(0).len(), 3);
let activations = g
.log()
.into_iter()
.filter(|e| *e == triggered(h, "deathrattle"))
.count();
assert_eq!(activations, 3);
}