Module 9 lesson, scenarios, tests (8, both tracks), reference engines (TURN phase list, conversion, content-registered hazards, win/lost), WEAK Expansion Drop, harness.md M9 semantics, research notes (WEAK page, baba-is-auto Game::MovePlayer, X IS NOT X placement reading). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
8.8 KiB
Rust
291 lines
8.8 KiB
Rust
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
|
//! read straight from JSON. The engine compiles each definition once, when it is
|
|
//! registered, into the lists it reads while resolving: triggers, auras and a
|
|
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// What a triggered ability listens for.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum TriggerOn {
|
|
/// This minion takes damage.
|
|
SelfDamaged,
|
|
/// A minion with the same owner (this one included) takes damage.
|
|
FriendlyMinionDamaged,
|
|
/// Any minion takes damage.
|
|
AnyMinionDamaged,
|
|
/// Either hero takes damage.
|
|
HeroDamaged,
|
|
/// This minion dies. It resolves after the minion has left play.
|
|
Deathrattle,
|
|
/// This minion is played from hand.
|
|
Battlecry,
|
|
/// The end of its owner's turn. The course has no turns, so it never fires; card
|
|
/// data may still name it, for example in a trigger multiplier.
|
|
EndOfTurn,
|
|
}
|
|
|
|
impl TriggerOn {
|
|
/// The trigger's family name, as reported in `Triggered` events.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
TriggerOn::SelfDamaged
|
|
| TriggerOn::FriendlyMinionDamaged
|
|
| TriggerOn::AnyMinionDamaged
|
|
| TriggerOn::HeroDamaged => "damaged",
|
|
TriggerOn::Deathrattle => "deathrattle",
|
|
TriggerOn::Battlecry => "battlecry",
|
|
TriggerOn::EndOfTurn => "endOfTurn",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How several trigger multipliers for the same trigger combine.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum Stacking {
|
|
/// The largest multiplier wins: two copies of a doubler still double.
|
|
Max,
|
|
/// Each copy adds its extra activations.
|
|
Additive,
|
|
}
|
|
|
|
/// An aura: a continuous effect a minion has while it is in play. The engine reads
|
|
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum Aura {
|
|
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
|
|
/// owner, activate `times` times each.
|
|
TriggerMultiplier {
|
|
trigger: TriggerOn,
|
|
times: u32,
|
|
stacking: Stacking,
|
|
},
|
|
}
|
|
|
|
/// Who an effect touches. `This` is the schema's `"self"`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum Target {
|
|
#[serde(rename = "self")]
|
|
This,
|
|
AllMinions,
|
|
AllOtherMinions,
|
|
AllOtherCharacters,
|
|
EnemyHero,
|
|
Chosen,
|
|
}
|
|
|
|
/// Which minions an Assemble may pick as its base. An empty filter allows any.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
|
pub struct PoolFilter {
|
|
/// Only minions with a trigger clause listening for this.
|
|
#[serde(default)]
|
|
pub trigger: Option<TriggerOn>,
|
|
}
|
|
|
|
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase")]
|
|
pub enum Effect {
|
|
DealDamage {
|
|
target: Target,
|
|
amount: i32,
|
|
},
|
|
Draw {
|
|
count: u32,
|
|
},
|
|
Summon {
|
|
card: String,
|
|
count: u32,
|
|
},
|
|
GainArmor {
|
|
amount: i32,
|
|
},
|
|
GainAttack {
|
|
target: Target,
|
|
amount: i32,
|
|
},
|
|
Destroy {
|
|
target: Target,
|
|
},
|
|
/// Part III (SpellGrid) only: puts a word on the grid.
|
|
SpawnText {
|
|
word: String,
|
|
at: String,
|
|
},
|
|
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
|
|
Assemble {
|
|
filter: PoolFilter,
|
|
},
|
|
/// Give every minion in the owner's hand +attack/+health.
|
|
BuffHand {
|
|
attack: i32,
|
|
health: i32,
|
|
},
|
|
}
|
|
|
|
/// One line of card text. A trigger clause applies to the effect clauses after it,
|
|
/// until the next trigger, keyword or aura. Effects before any trigger are what a
|
|
/// spell does when cast.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
pub enum Clause {
|
|
Keyword { keyword: String },
|
|
Trigger { on: TriggerOn },
|
|
Aura { modifier: Aura },
|
|
Effect { effect: Effect },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum CardType {
|
|
Minion,
|
|
Spell,
|
|
}
|
|
|
|
/// A card as data, in the course's card format. Fields the engine doesn't use
|
|
/// (`fictional`, `game`, `noun`) are ignored.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
pub struct CardDef {
|
|
pub name: String,
|
|
#[serde(rename = "type")]
|
|
pub card_type: CardType,
|
|
pub cost: i32,
|
|
#[serde(default)]
|
|
pub attack: i32,
|
|
#[serde(default)]
|
|
pub health: i32,
|
|
pub text: Vec<Clause>,
|
|
/// `text[..split]` is the top half, `text[split..]` the bottom half.
|
|
pub split: usize,
|
|
}
|
|
|
|
/// A triggered ability: "whenever <on>, <effects>".
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Trigger {
|
|
pub on: TriggerOn,
|
|
pub effects: Vec<Effect>,
|
|
}
|
|
|
|
/// A registered card: its definition, and the definition's text compiled into the
|
|
/// lists the engine reads while resolving.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Card {
|
|
pub def: CardDef,
|
|
/// Triggered abilities, in printed order.
|
|
pub triggers: Vec<Trigger>,
|
|
/// Auras this minion has while in play.
|
|
pub auras: Vec<Aura>,
|
|
/// What the card does when cast (a spell's effects).
|
|
pub on_cast: Vec<Effect>,
|
|
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
|
|
pub in_pool: bool,
|
|
}
|
|
|
|
impl Card {
|
|
/// Compile a definition's text. Panics if `split` is past the end of the text.
|
|
pub fn compile(def: CardDef, in_pool: bool) -> Card {
|
|
assert!(
|
|
def.split <= def.text.len(),
|
|
"{:?}: split {} is past the end of its text",
|
|
def.name,
|
|
def.split
|
|
);
|
|
let mut triggers: Vec<Trigger> = Vec::new();
|
|
let mut auras = Vec::new();
|
|
let mut on_cast = Vec::new();
|
|
// Is the latest clause a trigger whose effects are still being listed?
|
|
let mut open_trigger = false;
|
|
for clause in &def.text {
|
|
match clause {
|
|
Clause::Trigger { on } => {
|
|
triggers.push(Trigger {
|
|
on: *on,
|
|
effects: Vec::new(),
|
|
});
|
|
open_trigger = true;
|
|
}
|
|
Clause::Effect { effect } if open_trigger => {
|
|
triggers.last_mut().unwrap().effects.push(effect.clone());
|
|
}
|
|
Clause::Effect { effect } => on_cast.push(effect.clone()),
|
|
Clause::Aura { modifier } => {
|
|
auras.push(*modifier);
|
|
open_trigger = false;
|
|
}
|
|
// The course has no combat, so keywords such as Taunt do nothing here.
|
|
Clause::Keyword { .. } => open_trigger = false,
|
|
}
|
|
}
|
|
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
|
|
Card {
|
|
def,
|
|
triggers,
|
|
auras,
|
|
on_cast,
|
|
in_pool,
|
|
}
|
|
}
|
|
|
|
pub fn is_spell(&self) -> bool {
|
|
self.def.card_type == CardType::Spell
|
|
}
|
|
|
|
pub fn has_trigger(&self, on: TriggerOn) -> bool {
|
|
self.triggers.iter().any(|t| t.on == on)
|
|
}
|
|
}
|
|
|
|
/// Every card the game can create, looked up by name.
|
|
#[derive(Debug, Default)]
|
|
pub struct CardLibrary {
|
|
cards: HashMap<String, Card>,
|
|
}
|
|
|
|
impl CardLibrary {
|
|
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
|
|
let mut library = CardLibrary::default();
|
|
for def in defs {
|
|
library.register(def);
|
|
}
|
|
library
|
|
}
|
|
|
|
/// Add a card, or replace the card with the same name. It joins the Assemble pool
|
|
/// if it is a minion with text.
|
|
pub fn register(&mut self, def: CardDef) {
|
|
self.insert(Card::compile(def, true));
|
|
}
|
|
|
|
/// Add a card made by Assemble. It never joins the pool.
|
|
pub fn register_assembled(&mut self, def: CardDef) {
|
|
self.insert(Card::compile(def, false));
|
|
}
|
|
|
|
fn insert(&mut self, card: Card) {
|
|
self.cards.insert(card.def.name.clone(), card);
|
|
}
|
|
|
|
pub fn contains(&self, name: &str) -> bool {
|
|
self.cards.contains_key(name)
|
|
}
|
|
|
|
pub fn get(&self, name: &str) -> &Card {
|
|
self.cards
|
|
.get(name)
|
|
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
|
|
}
|
|
|
|
/// The Assemble pool: every card with `in_pool`, sorted by name.
|
|
pub fn pool(&self) -> Vec<&Card> {
|
|
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
|
|
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
|
|
pool
|
|
}
|
|
}
|