Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks, the cumulative reference (Module 11's plus combat) and the drop. The Damage action carries per-target hits with a priority; react stable-sorts its Queue by priority before order of play, so the triggers answering the defender's Damage Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness contract, catalog, research notes and earlier lessons' forward pointers follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
3.7 KiB
Rust
114 lines
3.7 KiB
Rust
// 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, Outcome};
|
|
|
|
/// Collects what the CLI prints.
|
|
#[derive(Clone, Default)]
|
|
struct Screen(Rc<RefCell<Vec<u8>>>);
|
|
|
|
impl Write for Screen {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
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}");
|
|
}
|