Engine events carry the nesting depth they were recorded at; the harness prints the log as an indented tree. New tests test_02_log_tree_shows_nesting and test_03_log_tree_death_phase; both SpellGrid CLIs print the tree of the last command's Sequences under a Log: line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
335 lines
11 KiB
Rust
335 lines
11 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, World};
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`.
|
|
impl std::fmt::Display for GameEvent {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"),
|
|
GameEvent::Damaged { target, amount } => {
|
|
write!(f, "Damaged{{target: {target}, amount: {amount}}}")
|
|
}
|
|
GameEvent::Triggered { source, trigger } => {
|
|
write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}")
|
|
}
|
|
GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"),
|
|
GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"),
|
|
GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"),
|
|
GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"),
|
|
GameEvent::ArmorGained { player, amount } => {
|
|
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
|
|
}
|
|
GameEvent::StatsChanged { id, attack, health } => {
|
|
write!(
|
|
f,
|
|
"StatsChanged{{id: {id}, attack: {attack}, health: {health}}}"
|
|
)
|
|
}
|
|
GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The log as a tree: one event per line, two spaces of indent per level of nesting.
|
|
fn log_tree(world: &World, view: impl Fn(&Event) -> Option<GameEvent>) -> String {
|
|
world
|
|
.log()
|
|
.iter()
|
|
.zip(world.log_depths())
|
|
.filter_map(|(event, &depth)| {
|
|
let shown = view(event)?;
|
|
Some(format!("{}{shown}\n", " ".repeat(depth as usize)))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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();
|
|
}
|
|
/// The log as an indented tree (see course/harness.md).
|
|
pub fn log_tree(&self) -> String {
|
|
log_tree(&self.world, GameEvent::from_engine)
|
|
}
|
|
|
|
// ---- 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, 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
|
|
}
|