// The SpellGrid command line, played from a script (the reference's own check; not a course test). use std::cell::RefCell; use std::io::{Cursor, Write}; use std::rc::Rc; use engine::cli::{run, run_with_cards, Outcome}; use engine::harness::{new_spell_grid, CardDef, LevelDef}; /// Collects what the CLI prints. #[derive(Clone, Default)] struct Screen(Rc>>); impl Write for Screen { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.borrow_mut().extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } fn play(level: &str, input: &[&str]) -> (Outcome, String) { let screen = Screen::default(); let input = Box::new(Cursor::new(input.join("\n") + "\n")); let outcome = run(level, input, Box::new(screen.clone())); let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); (outcome, out) } #[test] fn cli_wins_level_1() { let input = ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"]; let (outcome, out) = play(include_str!("../levels/l1-double-tap.json"), &input); assert_eq!(outcome, Outcome::Won); assert!(out.contains("DEA IS DOU")); assert!(out.contains("You win!")); } #[test] fn cli_wins_level_2_with_choices_and_play() { let input = [ "c Graft", "Ember Moth", "Rune Scribe", "bottom", "c Ember Moth (Assembled) 4 3", "a", "a", ]; let (outcome, out) = play(include_str!("../levels/l2-spliced-corpse.json"), &input); assert_eq!(outcome, Outcome::Won); assert!(out.contains("Choose the base")); assert!(out.contains("Hand: Ember Moth (Assembled)")); } #[test] fn cli_prints_the_log_tree_of_each_command() { let level = include_str!("../levels/l1-double-tap.json"); let (_, out) = play(level, &["w", "w", "c Whirlwind", "q"]); // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. assert_eq!(out.matches("Log:\n").count(), 1, "{out}"); let tree = [ "Log:", "SpellCast{card: \"Whirlwind\"}", "Damaged{target: 32, amount: 1}", "Damaged{target: 33, amount: 1}", "Destroyed{id: 32}", "DeathPhase{round: 1}", "Triggered{source: 32, trigger: \"deathrattle\"}", " Damaged{target: 33, amount: 1}", "Triggered{source: 32, trigger: \"deathrattle\"}", " Damaged{target: 33, amount: 1}", "Destroyed{id: 33}", "DeathPhase{round: 2}", "", ]; assert!(out.contains(&tree.join("\n")), "{out}"); } #[test] fn cli_reports_errors_and_quits() { let input = ["c Spark", "x", "q"]; let (outcome, out) = play( include_str!("../levels/l3-simultaneous-paradox.json"), &input, ); assert_eq!(outcome, Outcome::Quit); assert!(out.contains("\"Spark\" is not in the hand")); assert!(out.contains("Commands:")); } // The two reference CLIs print the same text for the same input (tools/diff-tracks.mjs checks it). #[test] fn cli_prints_card_names_as_typed() { let level = include_str!("../levels/l3-simultaneous-paradox.json"); let (_, out) = play(level, &["c a\"b", "q"]); assert!(out.contains("\"a\"b\" is not in the hand"), "{out}"); } #[test] fn cli_numbers_are_plain_digits() { // "-1" is not a coordinate, so it is part of the card's name. let level = include_str!("../levels/l1-double-tap.json"); let (_, out) = play(level, &["c Whirlwind -1 0", "q"]); assert!( out.contains("\"Whirlwind -1 0\" is not in the hand"), "{out}" ); // "+2" is not a number, so the chooser takes the first option. let level = include_str!("../levels/l2-spliced-corpse.json"); let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]); assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}"); } // Designer cards for the two tests below (as in a scenario for tools/run-scenario.mjs). const ZAP: &str = r#"{"name": "Zap", "type": "spell", "cost": 1, "split": 0, "text": [{"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; // Each Loop Ember's Deathrattle summons the next one and hits it: a Sequence with no end. const LOOP_EMBER: &str = r#"{"name": "Loop Ember", "type": "minion", "cost": 1, "attack": 1, "health": 1, "noun": "ROCK", "split": 1, "text": [{"kind": "trigger", "on": "deathrattle"}, {"kind": "effect", "effect": {"type": "summon", "card": "Loop Ember", "count": 1}}, {"kind": "effect", "effect": {"type": "dealDamage", "target": "allMinions", "amount": 1}}]}"#; // Its trigger names a card that does not exist, so it fails part-way through. const BAD_GOLEM: &str = r#"{"name": "Bad Golem", "type": "minion", "cost": 1, "attack": 1, "health": 3, "noun": "KEY", "split": 1, "text": [{"kind": "trigger", "on": "selfDamaged"}, {"kind": "effect", "effect": {"type": "summon", "card": "No Such Card", "count": 1}}]}"#; /// A 6x3 level with BABA IS YOU, three Zaps in the hand, and `card` on (3, 0). fn row(card: &str) -> String { format!( r#"{{"width": 6, "height": 3, "objects": [{{"kind": "baba", "x": 0, "y": 0}}, {{"kind": "text:BABA", "x": 0, "y": 2}}, {{"kind": "text:IS", "x": 1, "y": 2}}, {{"kind": "text:YOU", "x": 2, "y": 2}}], "cards": [{{"card": "{card}", "x": 3, "y": 0}}], "hand": ["Zap", "Zap", "Zap"]}}"# ) } fn cards(json: &[&str]) -> Vec { json.iter() .map(|j| serde_json::from_str(j).unwrap()) .collect() } #[test] fn cli_stops_when_a_sequence_does_not_finish() { let screen = Screen::default(); let input = Box::new(Cursor::new("c Zap\nd\nq\n")); let extra = cards(&[ZAP, LOOP_EMBER]); let outcome = run_with_cards(&row("Loop Ember"), extra, input, Box::new(screen.clone())); let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); assert_eq!(outcome, Outcome::Failed); // The error, then the end: no log tree of a half-done Sequence, no further commands read. let end = "> Death Phase limit: more than 64 Death Phases in one Sequence\n\ The Sequence did not finish, so the game cannot go on.\n"; assert!(out.ends_with(end), "{out}"); assert!(!out.contains("Log:"), "{out}"); } #[test] fn an_error_mid_sequence_leaves_no_trigger_resolving() { let level: LevelDef = serde_json::from_str(&row("Bad Golem")).unwrap(); let mut g = new_spell_grid(level, cards(&[ZAP, BAD_GOLEM])); let failed = |g: &mut engine::harness::SpellGrid| { let cause = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| g.cast("Zap", None))) .expect_err("the trigger names no card"); cause.downcast::().map(|m| *m).unwrap_or_default() }; assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); // The failed trigger closed its block: the next Sequence's log starts at depth 0, and // the golem's trigger is queued again (the nesting guard does not think it is still resolving). g.clear_log(); assert!(failed(&mut g).contains("unknown card \"No Such Card\"")); let tree = g.log_tree(); let lines: Vec<&str> = tree.lines().collect(); assert_eq!(lines.len(), 3, "{tree}"); assert_eq!(lines[0], "SpellCast{card: \"Zap\"}"); assert!(lines[1].starts_with("Damaged{target: "), "{tree}"); assert!(lines[2].starts_with("Triggered{source: "), "{tree}"); assert!(lines[2].ends_with(", trigger: \"damaged\"}"), "{tree}"); }