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:
1 parent
c8fff05ccb
commit
fb9d0a4eb4
89 files changed
+11852
-6
No files matched your search
@@ -0,0 +1,200 @@
|
||||
//! SpellGrid on the command line: `cargo run --bin spellgrid -- levels/l1-double-tap.json`.
|
||||
//!
|
||||
//! A thin loop over the harness: it draws the level, reads a command, and calls
|
||||
//! `step`, `cast` or `play`. No rules live here. After a command that ran Part I
|
||||
//! Sequences, it prints their events under `Log:` as an indented tree.
|
||||
//!
|
||||
//! ```text
|
||||
//! w a s d one turn: up, left, down, right
|
||||
//! c <card> [x y] cast a spell from your hand (at the minion on (x, y), if given),
|
||||
//! or play a minion from your hand onto the empty tile (x, y)
|
||||
//! q quit
|
||||
//! ```
|
||||
//!
|
||||
//! Assemble's choices (Graft) are asked for one at a time: answer with a number or a name.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid};
|
||||
|
||||
/// How a game ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Won,
|
||||
Lost,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// The player's input and the screen, shared with the chooser.
|
||||
struct Io {
|
||||
input: Box<dyn BufRead>,
|
||||
output: Box<dyn Write>,
|
||||
}
|
||||
|
||||
impl Io {
|
||||
fn read_line(&mut self) -> Option<String> {
|
||||
let mut line = String::new();
|
||||
match self.input.read_line(&mut line) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, s: &str) {
|
||||
let _ = self.output.write_all(s.as_bytes());
|
||||
let _ = self.output.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals.
|
||||
fn cell(g: &SpellGrid, x: i32, y: i32) -> String {
|
||||
let kinds = g.kinds_at(x, y);
|
||||
// `kinds_at` is sorted; prefer text, which is what the player reads.
|
||||
let Some(kind) = kinds
|
||||
.iter()
|
||||
.find(|k| k.starts_with("text:"))
|
||||
.or(kinds.first())
|
||||
else {
|
||||
return " . ".to_string();
|
||||
};
|
||||
let name = match kind.strip_prefix("text:") {
|
||||
Some(word) => word.to_uppercase(),
|
||||
None => kind.to_lowercase(),
|
||||
};
|
||||
format!("{:<3}", name.chars().take(3).collect::<String>())
|
||||
}
|
||||
|
||||
/// The level as text: the grid, the rules, the minions and the hand.
|
||||
pub fn render(g: &SpellGrid, level: &LevelDef) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for y in 0..level.height {
|
||||
let row: Vec<String> = (0..level.width).map(|x| cell(g, x, y)).collect();
|
||||
lines.push(row.join(" "));
|
||||
}
|
||||
lines.push(format!("Rules: {}", g.active_rules().join(", ")));
|
||||
let mut minions = Vec::new();
|
||||
for y in 0..level.height {
|
||||
for x in 0..level.width {
|
||||
if let Some(id) = g.minion_at(x, y) {
|
||||
let (name, health) = (g.name_of(id), g.health(id));
|
||||
minions.push(format!("{name} ({x},{y}) {health} Health"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let or = |list: Vec<String>, separator: &str, none: &str| {
|
||||
if list.is_empty() {
|
||||
none.to_string()
|
||||
} else {
|
||||
list.join(separator)
|
||||
}
|
||||
};
|
||||
lines.push(format!("Minions: {}", or(minions, "; ", "none")));
|
||||
lines.push(format!("Hand: {}", or(g.hand(), ", ", "empty")));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// A coordinate, or a chooser's number: one to nine digits, nothing else (no sign, no point).
|
||||
fn number(word: &str) -> Option<i32> {
|
||||
let digits = (1..=9).contains(&word.len()) && word.bytes().all(|b| b.is_ascii_digit());
|
||||
digits.then(|| word.parse().expect("nine digits fit an i32"))
|
||||
}
|
||||
|
||||
/// A `c` command: `c <card words...> [x y]`.
|
||||
fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) {
|
||||
let n = args.len();
|
||||
if n >= 3 {
|
||||
if let (Some(x), Some(y)) = (number(args[n - 2]), number(args[n - 1])) {
|
||||
return (args[..n - 2].join(" "), Some((x, y)));
|
||||
}
|
||||
}
|
||||
(args.join(" "), None)
|
||||
}
|
||||
|
||||
/// Play `level_text` (a level in the course's format), reading commands from `input`
|
||||
/// and writing to `output`.
|
||||
pub fn run(level_text: &str, input: Box<dyn BufRead>, output: Box<dyn Write>) -> Outcome {
|
||||
let level: LevelDef =
|
||||
serde_json::from_str(level_text).expect("a level in the course's level format");
|
||||
let mut g = new_spell_grid(level.clone(), vec![]);
|
||||
let io = Rc::new(RefCell::new(Io { input, output }));
|
||||
let chooser_io = Rc::clone(&io);
|
||||
g.set_chooser(move |kind, options| {
|
||||
let mut io = chooser_io.borrow_mut();
|
||||
let listed: Vec<String> = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, o)| format!(" {}) {o}", i + 1))
|
||||
.collect();
|
||||
io.write(&format!(
|
||||
"Choose the {kind} (a number or a name):\n{}\n? ",
|
||||
listed.join("\n")
|
||||
));
|
||||
let answer = io.read_line().unwrap_or_default().trim().to_string();
|
||||
if options.contains(&answer) {
|
||||
return answer;
|
||||
}
|
||||
let picked = number(&answer)
|
||||
.and_then(|n| usize::try_from(n - 1).ok())
|
||||
.and_then(|i| options.get(i));
|
||||
picked.unwrap_or(&options[0]).clone()
|
||||
});
|
||||
play(&mut g, &level, &io)
|
||||
}
|
||||
|
||||
/// Draw, read a command, do it; until the level is won or lost, or the player quits.
|
||||
fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc<RefCell<Io>>) -> Outcome {
|
||||
loop {
|
||||
io.borrow_mut().write(&format!("{}\n", render(g, level)));
|
||||
if g.won() {
|
||||
io.borrow_mut().write("You win!\n");
|
||||
return Outcome::Won;
|
||||
}
|
||||
if g.lost() {
|
||||
io.borrow_mut().write("Nothing is YOU. You lose.\n");
|
||||
return Outcome::Lost;
|
||||
}
|
||||
io.borrow_mut().write("> ");
|
||||
let Some(line) = io.borrow_mut().read_line() else {
|
||||
return Outcome::Quit;
|
||||
};
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
// The log holds only what this command does.
|
||||
g.clear_log();
|
||||
let dir = match words.first() {
|
||||
Some(&"q") => return Outcome::Quit,
|
||||
Some(&"w") => Some(Dir::Up),
|
||||
Some(&"a") => Some(Dir::Left),
|
||||
Some(&"s") => Some(Dir::Down),
|
||||
Some(&"d") => Some(Dir::Right),
|
||||
_ => None,
|
||||
};
|
||||
// The harness panics on a move the rules don't allow (a card not in the hand, a
|
||||
// full tile). The CLI asks first, and reports the reason instead.
|
||||
let done = match (dir, words.as_slice()) {
|
||||
(Some(dir), _) => Ok(g.step(dir)),
|
||||
(None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) {
|
||||
(card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)),
|
||||
(card, Some((x, y))) => match g.minion_at(x, y) {
|
||||
Some(target) => g
|
||||
.check_cast(&card, Some(target))
|
||||
.map(|()| g.cast(&card, Some(target))),
|
||||
None => g.check_play(&card, x, y).map(|()| {
|
||||
g.play(&card, x, y);
|
||||
}),
|
||||
},
|
||||
},
|
||||
_ => {
|
||||
Err("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)".to_string())
|
||||
}
|
||||
};
|
||||
if let Err(message) = done {
|
||||
io.borrow_mut().write(&format!("{message}\n"));
|
||||
}
|
||||
let tree = g.log_tree();
|
||||
if !tree.is_empty() {
|
||||
io.borrow_mut().write(&format!("Log:\n{tree}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user