Summoned records its summoner (the minion whose trigger is resolving) inside the engine; the harness log is unchanged. When a Sequence ends, SpellGrid writes spawned text, places each summoned minion with a noun on its summoner's tile, removes the dead, reparses and syncs rule auras. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
530 lines
18 KiB
Rust
530 lines
18 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 },
|
|
ArmorLost { 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::ArmorLost { player, amount } => GameEvent::ArmorLost { 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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::ArmorLost { player, amount } => {
|
|
write!(f, "ArmorLost{{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, 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
|
|
}
|
|
|
|
/// The kinds on tile (x, y), as `kinds_at`, but oldest object first.
|
|
pub fn stack_at(&self, x: i32, y: i32) -> Vec<String> {
|
|
let board = self.level.board();
|
|
board.at(x, y).map(|o| o.kind.to_string()).collect()
|
|
}
|
|
|
|
// ---- 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()
|
|
}
|
|
|
|
/// Put the level back as it was before the last `step` or `move_dir`. False, and
|
|
/// no change, if there is none; repeated calls walk further back.
|
|
pub fn undo(&mut self) -> bool {
|
|
self.level.undo()
|
|
}
|
|
}
|
|
|
|
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 stack_at(&self, x: i32, y: i32) -> Vec<String> {
|
|
let board = self.grid.level().board();
|
|
board.at(x, y).map(|o| o.kind.to_string()).collect()
|
|
}
|
|
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()
|
|
}
|
|
/// Not supported: Part I has state (health, hands, the log) that the level's
|
|
/// snapshot doesn't cover. Panics.
|
|
pub fn undo(&mut self) -> bool {
|
|
panic!("undo is not supported in SpellGrid");
|
|
}
|
|
|
|
// ---- 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()
|
|
}
|
|
/// The course's view of an engine event, with every entity id as a grid id.
|
|
fn grid_event(&self, event: &Event) -> Option<GameEvent> {
|
|
let id = |entity: Id| self.grid.id_of(entity);
|
|
Some(match GameEvent::from_engine(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,
|
|
})
|
|
}
|
|
/// Part I's log, with grid ids.
|
|
pub fn log(&self) -> Vec<GameEvent> {
|
|
let log = self.grid.world().log();
|
|
log.iter().filter_map(|e| self.grid_event(e)).collect()
|
|
}
|
|
pub fn clear_log(&mut self) {
|
|
self.grid.world_mut().clear_log();
|
|
}
|
|
/// Part I's log as an indented tree, with grid ids (see course/harness.md).
|
|
pub fn log_tree(&self) -> String {
|
|
log_tree(self.grid.world(), |e| self.grid_event(e))
|
|
}
|
|
/// 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);
|
|
}
|
|
}
|