Files
systemic-game-design-course/course/10-spellgrid/reference/rust/src/harness.rs
T
Lucas WintherandClaude Opus 5.5 6f576940b7 fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:11:09 +01:00

455 lines
15 KiB
Rust

//! Test harness: the thin adapter the course tests call. It translates each
//! call into engine operations and holds no rules of its own.
use crate::content;
use crate::engine::{
self, puzzle, run_sequence, summon, Action, CardLibrary, Event, SpellGrid as Grid, World,
END_OF_PHASE,
};
pub use crate::engine::{AssembleError, CardDef, Half, Zone};
pub type Id = u32;
pub type Player = u8;
#[derive(Debug, Default, Clone)]
pub struct GameOpts {
pub decks: Option<[Vec<String>; 2]>,
}
/// The events the tests read from `log()` (see course/harness.md).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameEvent {
Summoned { id: Id },
Damaged { target: Id, amount: i32 },
Triggered { source: Id, trigger: String },
Destroyed { id: Id },
DeathPhase { round: u32 },
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
impl GameEvent {
/// The course's view of an engine event; `None` for engine events the course log
/// doesn't show.
fn from_engine(event: &Event) -> Option<GameEvent> {
Some(match event.clone() {
Event::Summoned { id } => GameEvent::Summoned { id },
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
Event::Triggered { source, on } => GameEvent::Triggered {
source,
trigger: on.label().to_string(),
},
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
Event::SpellCast { card } => GameEvent::SpellCast { card },
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
// A played minion's `Summoned` already shows it entering the board.
Event::Played { .. } => return None,
// SpellGrid writes the text on the grid; the course's log doesn't show the request.
Event::TextSpawned { .. } => return None,
})
}
}
pub struct Game {
world: World,
}
pub fn new_game(opts: GameOpts) -> Game {
let mut world = World::new(CardLibrary::new(content::cards()));
if let Some(decks) = opts.decks {
for (player, deck) in decks.iter().enumerate() {
for card in deck {
world.create(card, player as Player, Zone::Deck);
}
}
}
Game { world }
}
impl Game {
/// Put `card` at the right end of `player`'s board.
pub fn summon(&mut self, card: &str, player: Player) -> Id {
summon(&mut self.world, card, player, None)
}
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Hand)
}
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Deck)
}
/// Play a card from hand, as one Sequence: it enters the right end of the board, then
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
pub fn play(&mut self, hand_id: Id) {
run_sequence(&mut self.world, Action::Play { id: hand_id });
}
pub fn zone_of(&self, id: Id) -> Zone {
self.world.zone_of(id)
}
/// Board ids, left to right.
pub fn board(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Board).to_vec()
}
pub fn hand(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Hand).to_vec()
}
pub fn order_of_play(&self, id: Id) -> u32 {
self.world
.order_of_play(id)
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
}
pub fn name_of(&self, id: Id) -> String {
self.world.card_name(id).to_string()
}
// ---- Module 2 ----
pub fn hero(&self, player: Player) -> Id {
self.world.hero(player)
}
/// Deal damage to one character, as one Sequence.
pub fn deal_damage(&mut self, target: Id, amount: i32) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Damage { targets, amount });
}
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
let card = card.to_string();
run_sequence(
&mut self.world,
Action::Cast {
card,
player,
target,
},
);
}
pub fn health(&self, id: Id) -> i32 {
self.world.health(id)
}
pub fn attack(&self, id: Id) -> i32 {
self.world.attack(id)
}
pub fn armor(&self, player: Player) -> i32 {
self.world.armor(player)
}
pub fn log(&self) -> Vec<GameEvent> {
self.world
.log()
.iter()
.filter_map(GameEvent::from_engine)
.collect()
}
pub fn clear_log(&mut self) {
self.world.clear_log();
}
// ---- Module 3 ----
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
pub fn destroy(&mut self, target: Id) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Destroy { targets });
}
// ---- Module 5 ----
/// Add card definitions to this game, replacing any with the same name.
pub fn register_cards(&mut self, defs: Vec<CardDef>) {
for def in defs {
self.world.register(def);
}
}
/// Register `base` with `half` of its text replaced by the same half of `donor`,
/// and return the new card's name, `"<base> (Assembled)"`.
pub fn assemble(
&mut self,
base: &str,
donor: &str,
half: Half,
) -> Result<String, AssembleError> {
let base = &self.world.card(base).def;
let donor = &self.world.card(donor).def;
let def = engine::assemble(base, donor, half)?;
Ok(engine::register_assembled(&mut self.world, def))
}
pub fn cost(&self, id: Id) -> i32 {
self.world.cost(id)
}
/// Who makes choices (Assemble's base, donor and half): called with the kind of
/// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them.
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.world.set_chooser(Box::new(chooser));
}
}
// ======== Part II: Baba Is You ========
pub use crate::engine::puzzle::{Dir, LevelCard, LevelDef, LevelObject};
/// A Baba Is You level in play (Part II).
pub struct Baba {
level: puzzle::Level,
}
/// A level from a level definition; its text is parsed straight away.
pub fn new_baba(level: LevelDef) -> Baba {
Baba {
level: puzzle::Level::new(
&level,
&content::words(),
content::movement(),
content::hazards(),
),
}
}
impl Baba {
// ---- Module 6 ----
/// Add an object (`"baba"`) or a piece of text (`"text:BABA"`) at (x, y).
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id {
self.level.place(kind, x, y)
}
/// Every distinct rule the text spells, before resolution, normalised
/// (`"BABA IS YOU"`, `"BABA IS NOT YOU"`) and sorted.
pub fn rules(&self) -> Vec<String> {
normalised(self.level.rules())
}
// ---- Module 7 ----
/// Every distinct rule in force after resolution, normalised and sorted.
pub fn active_rules(&self) -> Vec<String> {
normalised(self.level.active_rules())
}
/// Does object `id` have the property `prop` under the rules in force?
pub fn has_property(&self, id: Id, prop: &str) -> bool {
self.level.has_property(id, prop)
}
// ---- Module 8 ----
/// Move every `YOU` object one tile in `dir`, pushing what it pushes, then
/// reparse the rules. Movement only: no conversion, hazard or win (Module 9).
pub fn move_dir(&mut self, dir: Dir) {
self.level.move_you(dir);
}
/// Where object `id` is, or `None` once it has been destroyed.
pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> {
self.level.board().find(id).map(|o| (o.x, o.y))
}
/// The kinds of the objects on tile (x, y), as placed (`"baba"`, `"text:ROCK"`), sorted.
pub fn kinds_at(&self, x: i32, y: i32) -> Vec<String> {
let mut kinds: Vec<String> = self
.level
.board()
.at(x, y)
.map(|o| o.kind.to_string())
.collect();
kinds.sort();
kinds
}
// ---- Module 9 ----
/// Play one whole turn: movement, parse, conversion, parse, hazards, win
/// (and a last parse, so the rules match the board).
pub fn step(&mut self, dir: Dir) {
self.level.step(dir);
}
/// Has a turn found a `YOU` object on a `WIN` one (or `WIN` itself)? Stays true.
pub fn won(&self) -> bool {
self.level.won()
}
/// Is no object `YOU`?
pub fn lost(&self) -> bool {
self.level.lost()
}
}
fn normalised(rules: &[puzzle::Rule]) -> Vec<String> {
let mut rules: Vec<String> = rules.iter().map(|r| r.to_string()).collect();
rules.sort();
rules.dedup();
rules
}
// ======== Part III: SpellGrid ========
/// A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards.
/// Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.rs`).
pub struct SpellGrid {
grid: Grid,
}
/// A SpellGrid level; `extra` cards are added to the course's cards, replacing any
/// with the same name.
pub fn new_spell_grid(level: LevelDef, extra: Vec<CardDef>) -> SpellGrid {
let mut library = CardLibrary::new(content::cards());
for def in extra {
library.register(def);
}
SpellGrid {
grid: Grid::new(
&level,
library,
&content::words(),
content::movement(),
content::hazards(),
content::seams(),
),
}
}
impl SpellGrid {
// ---- Part II, as on `Baba` ----
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id {
self.grid.place(kind, x, y)
}
pub fn rules(&self) -> Vec<String> {
normalised(self.grid.level().rules())
}
pub fn active_rules(&self) -> Vec<String> {
normalised(self.grid.level().active_rules())
}
pub fn has_property(&self, id: Id, prop: &str) -> bool {
self.grid.level().has_property(id, prop)
}
pub fn move_dir(&mut self, dir: Dir) {
self.grid.move_dir(dir);
}
pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> {
self.grid.level().board().find(id).map(|o| (o.x, o.y))
}
pub fn kinds_at(&self, x: i32, y: i32) -> Vec<String> {
let mut kinds: Vec<String> = self
.grid
.level()
.board()
.at(x, y)
.map(|o| o.kind.to_string())
.collect();
kinds.sort();
kinds
}
pub fn step(&mut self, dir: Dir) {
self.grid.step(dir);
}
pub fn won(&self) -> bool {
self.grid.level().won()
}
pub fn lost(&self) -> bool {
self.grid.level().lost()
}
// ---- Module 10 ----
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
pub fn cast(&mut self, card: &str, target: Option<Id>) {
self.grid.cast(card, target);
}
/// Play a minion from player 0's hand onto the empty tile (x, y); returns its id.
pub fn play(&mut self, card: &str, x: i32, y: i32) -> Id {
self.grid.play(card, x, y)
}
pub fn health(&self, id: Id) -> i32 {
self.grid.world().health(self.grid.entity(id))
}
pub fn hero(&self, player: Player) -> Id {
self.grid.id_of(self.grid.world().hero(player))
}
pub fn name_of(&self, id: Id) -> String {
self.grid
.world()
.card_name(self.grid.entity(id))
.to_string()
}
/// The minion on tile (x, y), if any.
pub fn minion_at(&self, x: i32, y: i32) -> Option<Id> {
self.grid.minion_at(x, y)
}
/// The card names in player 0's hand, in order.
pub fn hand(&self) -> Vec<String> {
let world = self.grid.world();
world
.zone(0, Zone::Hand)
.iter()
.map(|&id| world.card_name(id).to_string())
.collect()
}
/// Part I's log, with grid ids.
pub fn log(&self) -> Vec<GameEvent> {
let id = |entity: Id| self.grid.id_of(entity);
self.grid
.world()
.log()
.iter()
.filter_map(GameEvent::from_engine)
.map(|event| match event {
GameEvent::Summoned { id: e } => GameEvent::Summoned { id: id(e) },
GameEvent::Destroyed { id: e } => GameEvent::Destroyed { id: id(e) },
GameEvent::Drew { player, id: e } => GameEvent::Drew { player, id: id(e) },
GameEvent::StatsChanged {
id: e,
attack,
health,
} => GameEvent::StatsChanged {
id: id(e),
attack,
health,
},
GameEvent::Damaged { target, amount } => GameEvent::Damaged {
target: id(target),
amount,
},
GameEvent::Triggered { source, trigger } => GameEvent::Triggered {
source: id(source),
trigger,
},
other => other,
})
.collect()
}
pub fn clear_log(&mut self) {
self.grid.world_mut().clear_log();
}
/// Who makes Assemble's choices (`"base"`, `"donor"`, `"half"`).
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.grid.world_mut().set_chooser(Box::new(chooser));
}
/// For the command line: why `cast` would panic, or `Ok` if it wouldn't.
pub fn check_cast(&self, card: &str, target: Option<Id>) -> Result<(), String> {
self.grid.check_cast(card, target)
}
/// For the command line: why `play` would panic, or `Ok` if it wouldn't.
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
self.grid.check_play(card, x, y)
}
/// Test hook: the naive engine reads the auras before the dead leave play, the
/// same end-of-Phase steps reversed.
pub fn with_naive_aura_timing(&mut self) {
let mut steps = END_OF_PHASE.to_vec();
steps.reverse();
self.grid.world_mut().set_end_of_phase(steps);
}
}