feat: module 10 spellgrid
SpellGrid capstone: Part I's card engine and Part II's grid engine joined at three seams (rules -> aura data, hazard victims -> one destroy Sequence, Sequence end -> write text, remove the dead, reparse). Three levels with scripted solutions, a naive-timing test via a harness-only hook that reverses the END_OF_PHASE list, CLIs in both tracks, the BATTLECRY IS DOUBLE Expansion Drop, SpellGrid cards and words in course/cards and the catalog, level schema cards/hand, and tools staging/copying of module levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f3359cfe2
commit
a0cf9be214
96 files changed
+10454
-8
No files matched your search
@@ -0,0 +1,297 @@
|
||||
//! 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,
|
||||
},
|
||||
/// Module 10: triggers listening for `trigger` activate at most `times` times,
|
||||
/// whatever multiplies them. Not in the card format: SpellGrid compiles it from a
|
||||
/// negated rule (`DEATHRATTLE IS NOT DOUBLE`).
|
||||
TriggerCap { trigger: TriggerOn, times: u32 },
|
||||
}
|
||||
|
||||
/// 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`) are ignored. `noun` is read by SpellGrid (Module 10).
|
||||
#[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,
|
||||
/// The noun a SpellGrid minion has on the grid (Module 10).
|
||||
#[serde(default)]
|
||||
pub noun: Option<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user