feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)

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>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 13:34:20 +01:00
1 parent c8fff05ccb
commit fb9d0a4eb4
89 files changed
+11852 -6

No files matched your search

@@ -0,0 +1,113 @@
// 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}");
}