Files
systemic-game-design-course/course/12-combat/reference/rust/src/cli.rs
T
Lucas WintherandClaude Opus 5.5 fb9d0a4eb4 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>
2026-11-11 13:34:20 +01:00

201 lines
7.1 KiB
Rust

//! 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}"));
}
}
}