Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
6.4 KiB
Rust
191 lines
6.4 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, 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|