// 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 { let mut names: Vec = 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 { 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_assemble_never_rewrites_an_earlier_card() { let mut g = new_game(GameOpts::default()); let first = g .assemble("Harvest Golem", "Dread Infernal", Half::Bottom) .unwrap(); let a = g.add_to_hand(&first, 0); // The base changes (a new cost, the same text), then the same Assemble is made again. let (_, mut golem) = card_files() .into_iter() .find(|(_, def)| def.name == "Harvest Golem") .unwrap(); golem.cost = 5; g.register_cards(vec![golem]); let second = g .assemble("Harvest Golem", "Dread Infernal", Half::Bottom) .unwrap(); let b = g.add_to_hand(&second, 0); // A different card, so a different name: the first card in hand is still the one it was. assert_ne!(second, first); assert_eq!(g.cost(a), 3); assert_eq!(g.cost(b), 5); } #[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)>>> = 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); } /// A one-off card, written as data in the test. fn card(json: &str) -> CardDef { serde_json::from_str(json).unwrap() } /// The `Triggered` events of a log, in order. fn triggers(log: &[GameEvent]) -> Vec { log.iter() .filter(|e| matches!(e, GameEvent::Triggered { .. })) .cloned() .collect() } /// "Deathrattle: Summon a Baron Rivendare." const TEST_HERALD: &str = r#"{ "name": "Test Herald", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Baron Rivendare", "count": 1 } }] }"#; /// "Battlecry: Draw a card. Your Battlecries trigger twice." const TEST_BRAGGART: &str = r#"{ "name": "Test Braggart", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "draw", "count": 1 } }, { "kind": "aura", "modifier": { "type": "triggerMultiplier", "trigger": "battlecry", "times": 2, "stacking": "max" } }] }"#; #[test] fn test_05_summoned_multiplier_counts_for_later_entries() { // Herald is older: its Deathrattle resolves first and summons Baron before the Hoarder's starts. let mut a = new_game(GameOpts { decks: Some([fillers(5), vec![]]), }); a.register_cards(vec![card(TEST_HERALD)]); a.summon("Test Herald", 0); let hoarder_a = a.summon("Loot Hoarder", 0); a.clear_log(); a.cast("Twisting Nether", 0, None); let of_hoarder = |log: &[GameEvent], h: Id| { log.iter() .filter(|e| matches!(e, GameEvent::Triggered { source, .. } if *source == h)) .count() }; assert_eq!(of_hoarder(&a.log(), hoarder_a), 2); assert_eq!(a.hand(0).len(), 2); let names: Vec = a.board(0).iter().map(|&id| a.name_of(id)).collect(); assert_eq!(names, vec!["Baron Rivendare"]); // The Hoarder is older: its Deathrattle has started, and finished, before any Baron exists. let mut b = new_game(GameOpts { decks: Some([fillers(5), vec![]]), }); b.register_cards(vec![card(TEST_HERALD)]); let hoarder_b = b.summon("Loot Hoarder", 0); b.summon("Test Herald", 0); b.clear_log(); b.cast("Twisting Nether", 0, None); assert_eq!(of_hoarder(&b.log(), hoarder_b), 1); assert_eq!(b.hand(0).len(), 1); let names: Vec = b.board(0).iter().map(|&id| b.name_of(id)).collect(); assert_eq!(names, vec!["Baron Rivendare"]); } #[test] fn test_05_own_aura_misses_own_battlecry() { let mut g = new_game(GameOpts { decks: Some([fillers(5), vec![]]), }); g.register_cards(vec![card(TEST_BRAGGART)]); let first = g.add_to_hand("Test Braggart", 0); let second = g.add_to_hand("Test Braggart", 0); g.clear_log(); g.play(first); // Playing is not summoning: the Braggart's own aura joins at the end of the Phase. assert_eq!(triggers(&g.log()), vec![triggered(first, "battlecry")]); assert_eq!(g.hand(0).len(), 2); g.clear_log(); g.play(second); // The first Braggart was already in play: the second one's Battlecry triggers twice. assert_eq!( triggers(&g.log()), vec![triggered(second, "battlecry"), triggered(second, "battlecry")] ); assert_eq!(g.hand(0).len(), 3); } /// "Whenever a minion takes damage, deal 1 damage to all minions." const TEST_BOMB: &str = r#"{ "name": "Test Bomb", "type": "minion", "cost": 1, "attack": 1, "health": 50, "split": 1, "text": [{ "kind": "trigger", "on": "anyMinionDamaged" }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#; #[test] fn test_05_trigger_does_not_nest_in_itself() { let mut g = new_game(GameOpts::default()); g.register_cards(vec![card(TEST_BOMB)]); let b1 = g.summon("Test Bomb", 0); let b2 = g.summon("Test Bomb", 0); g.clear_log(); g.deal_damage(b1, 1); // A trigger is never queued while that same minion's same trigger is resolving. let expected: Vec = [b1, b2, b2, b2, b1, b1] .iter() .map(|&source| triggered(source, "damaged")) .collect(); assert_eq!(triggers(&g.log()), expected); let damage = g .log() .iter() .filter(|e| matches!(e, GameEvent::Damaged { .. })) .count(); assert_eq!(damage, 13); assert_eq!(g.health(b1), 43); assert_eq!(g.health(b2), 44); } /// "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." const TEST_EMBER: &str = r#"{ "name": "Test Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Ember", "count": 1 } }, { "kind": "effect", "effect": { "type": "dealDamage", "target": "allMinions", "amount": 1 } }] }"#; #[test] fn test_05_death_phase_limit() { let mut g = new_game(GameOpts::default()); g.register_cards(vec![card(TEST_EMBER)]); let e = g.summon("Test Ember", 0); g.clear_log(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.deal_damage(e, 1))); let payload = result.expect_err("more than 64 Death Phases must panic"); let message = payload .downcast_ref::() .cloned() .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) .unwrap_or_default(); assert!(message.contains("Death Phase limit"), "{message}"); // Death Phases 1 to 64 ran; the 65th was refused before it started. let phases: Vec = g .log() .into_iter() .filter(|e| matches!(e, GameEvent::DeathPhase { .. })) .collect(); assert_eq!(phases.len(), 64); assert_eq!(phases[63], GameEvent::DeathPhase { round: 64 }); // The panic is raised between Death Phases, where no trigger is resolving: the next event is at depth 0. g.clear_log(); let f = g.summon("Test Filler", 0); assert_eq!(g.log_tree(), format!("Summoned{{id: {f}}}\n")); } #[test] fn test_05_assemble_rejects_ill_formed_text() { let mut g = new_game(GameOpts::default()); // Top: "Draw a card." with no trigger. Bottom: a Deathrattle with no effect, then Baron's aura. let top = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Top); let bottom = g.assemble("Loot Hoarder", "Baron Rivendare", Half::Bottom); assert!(top.is_err(), "{top:?}"); assert!(bottom.is_err(), "{bottom:?}"); let calls: Rc)>>> = 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" => "Loot Hoarder", _ => "top", } .to_string() }); g.cast("Test Workshop", 0, None); // A minion whose whole text is one aura has no half that fits around a trigger and its effect. let aura_only: Vec = card_files() .into_iter() .filter(|(raw, _)| raw["type"] == "minion") .filter(|(raw, _)| { let text = raw["text"].as_array().unwrap(); !text.is_empty() && text.iter().all(|c| c["kind"] == "aura") }) .map(|(raw, _)| raw["name"].as_str().unwrap().to_string()) .collect(); assert!(aura_only.contains(&"Baron Rivendare".to_string())); assert!(aura_only.contains(&"Bolf Ramshield".to_string())); let donors = calls.borrow()[1].1.clone(); assert!(donors.contains(&"Loot Hoarder".to_string())); for name in &aura_only { assert!(!donors.contains(name), "{name} offered: {donors:?}"); } } /// "Battlecry: Summon two Test Fillers. Deathrattle: Summon two Test Fillers." const TEST_BROOD: &str = r#"{ "name": "Test Brood", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 2, "text": [{ "kind": "trigger", "on": "battlecry" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }, { "kind": "trigger", "on": "deathrattle" }, { "kind": "effect", "effect": { "type": "summon", "card": "Test Filler", "count": 2 } }] }"#; #[test] fn test_05_multi_summon_order() { let mut g = new_game(GameOpts::default()); g.register_cards(vec![card(TEST_BROOD)]); let f1 = g.summon("Test Filler", 0); let b = g.add_to_hand("Test Brood", 0); g.clear_log(); g.play(b); // Ids are issued in summon order: the first token is the smaller id, and it stands further left. let summoned = |log: Vec| -> Vec { log.into_iter() .filter_map(|e| match e { GameEvent::Summoned { id } if id != b => Some(id), _ => None, }) .collect() }; let tokens = summoned(g.log()); let (t1, t2) = (tokens[0], tokens[1]); assert!(t1 < t2); assert_eq!(g.board(0), vec![f1, b, t1, t2]); let f2 = g.summon("Test Filler", 0); g.clear_log(); g.destroy(b); let tokens = summoned(g.log()); let (t3, t4) = (tokens[0], tokens[1]); assert!(t3 < t4); assert_eq!(g.board(0), vec![f1, t3, t4, t1, t2, f2]); }