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>
410 lines
15 KiB
Rust
410 lines
15 KiB
Rust
//! Part III: SpellGrid, the two engines in one level.
|
|
//!
|
|
//! Part I (the card engine) and Part II (the grid engine, `puzzle`) don't use each
|
|
//! other. This file is the only place that knows both, and they meet at four seams:
|
|
//!
|
|
//! 1. Rules become modifiers. After every reparse, the rules in force that join a
|
|
//! trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into
|
|
//! auras, and handed to Part I with `World::set_rule_auras`. A negated one
|
|
//! (`DEATHRATTLE IS NOT DOUBLE`) becomes a cap. Which words are which is content's
|
|
//! data (`SeamWords`), not code here.
|
|
//! 2. Hazards mark, the death step removes. The grid engine hands the objects a turn
|
|
//! destroys to `CardSide::destroy`. A minion among them is marked pending destroy,
|
|
//! in one Sequence, and Part I's Death Creation Step removes it.
|
|
//! 3. Grid changes reparse. While a Sequence runs, the grid doesn't change. When it
|
|
//! ends, the text its effects asked for (`TextSpawned` events) is written, the
|
|
//! minions that died (`Destroyed` events) leave the grid, and the rules are parsed
|
|
//! again, before anything else happens.
|
|
//! 4. Summoned minions enter the grid. In the same place, before the dead leave: a
|
|
//! minion that a minion's ability summoned (`Summoned` events with a `by`), whose card
|
|
//! has a noun, becomes an object of that noun on its summoner's tile. One summoned by
|
|
//! a spell, or with no noun, is in play but not on the grid.
|
|
//!
|
|
//! One id space: a minion's id is its grid object's id. Every other card (a hero, a
|
|
//! card in hand) gets an id from the same counter that no object will have.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use serde::Deserialize;
|
|
|
|
use super::aura::aura_update_other;
|
|
use super::card::{Aura, CardLibrary, CardType, Effect, Stacking, Target, TriggerOn};
|
|
use super::event::Event;
|
|
use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef};
|
|
use super::queue::{run_sequence, Action};
|
|
use super::world::{EntityId, World, Zone};
|
|
|
|
/// A multiplier word's effect: `"DOUBLE": { "times": 2, "stacking": "max" }`.
|
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
|
pub struct Multiplier {
|
|
pub times: u32,
|
|
pub stacking: Stacking,
|
|
}
|
|
|
|
/// The words that cross the seam, from content: nouns that name a trigger
|
|
/// (`"DEATHRATTLE": "deathrattle"`) and properties that multiply one.
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SeamWords {
|
|
pub trigger_words: HashMap<String, TriggerOn>,
|
|
pub multiplier_words: HashMap<String, Multiplier>,
|
|
}
|
|
|
|
/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a
|
|
/// multiplier word) multiplies T's triggers; `T IS NOT M` caps them at one activation,
|
|
/// so it cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`.
|
|
pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec<Aura> {
|
|
let mut auras = Vec::new();
|
|
for rule in rules {
|
|
let (Some(&trigger), Some(&m)) = (
|
|
words.trigger_words.get(&rule.subject),
|
|
words.multiplier_words.get(&rule.complement),
|
|
) else {
|
|
continue;
|
|
};
|
|
auras.push(if rule.negated {
|
|
Aura::TriggerCap { trigger, times: 1 }
|
|
} else {
|
|
Aura::TriggerMultiplier {
|
|
trigger,
|
|
times: m.times,
|
|
stacking: m.stacking,
|
|
}
|
|
});
|
|
}
|
|
auras
|
|
}
|
|
|
|
/// A SpellGrid level in play: a grid and a game, kept in step. Ids are grid ids (see above).
|
|
pub struct SpellGrid {
|
|
level: Level,
|
|
side: CardSide,
|
|
}
|
|
|
|
/// Part I's half: the game, and which entity is which id. Kept apart from the level so
|
|
/// that the level can lend itself to the destroy function while this half changes.
|
|
struct CardSide {
|
|
world: World,
|
|
/// Every entity's id.
|
|
ids: HashMap<EntityId, ObjectId>,
|
|
/// The entity behind each id (a card's old id still finds it after it is played).
|
|
entities: HashMap<ObjectId, EntityId>,
|
|
seam_words: SeamWords,
|
|
}
|
|
|
|
impl SpellGrid {
|
|
/// The level's objects; its `cards` as minions in play for player 0, each a grid
|
|
/// object named by the card's noun; its `hand` in player 0's hand.
|
|
pub fn new(
|
|
def: &LevelDef,
|
|
library: CardLibrary,
|
|
words: &[WordDef],
|
|
responses: Vec<Response>,
|
|
hazards: Vec<Hazard>,
|
|
seam_words: SeamWords,
|
|
) -> SpellGrid {
|
|
let mut level = Level::new(def, words, responses, hazards);
|
|
let mut side = CardSide {
|
|
world: World::new(library),
|
|
ids: HashMap::new(),
|
|
entities: HashMap::new(),
|
|
seam_words,
|
|
};
|
|
for c in &def.cards {
|
|
let kind = side.noun_of(&c.card);
|
|
let id = level.board_mut().place(kind, c.x, c.y);
|
|
let entity = side.world.create(&c.card, 0, Zone::Board);
|
|
side.link(entity, id);
|
|
}
|
|
// Every minion is in play: one Aura Update, as for a summon.
|
|
aura_update_other(&mut side.world);
|
|
for card in &def.hand {
|
|
side.world.create(card, 0, Zone::Hand);
|
|
}
|
|
side.link_the_rest(&mut level);
|
|
side.grid_changed(&mut level);
|
|
SpellGrid { level, side }
|
|
}
|
|
|
|
pub fn level(&self) -> &Level {
|
|
&self.level
|
|
}
|
|
|
|
pub fn world(&self) -> &World {
|
|
&self.side.world
|
|
}
|
|
|
|
pub fn world_mut(&mut self) -> &mut World {
|
|
&mut self.side.world
|
|
}
|
|
|
|
// ---- Ids ----
|
|
|
|
/// The id of entity `entity`.
|
|
pub fn id_of(&self, entity: EntityId) -> ObjectId {
|
|
self.side.ids[&entity]
|
|
}
|
|
|
|
/// The entity behind `id`. Panics if `id` is not a card's.
|
|
pub fn entity(&self, id: ObjectId) -> EntityId {
|
|
self.side.entity(id)
|
|
}
|
|
|
|
/// The id of the minion on tile (x, y), if there is one.
|
|
pub fn minion_at(&self, x: i32, y: i32) -> Option<ObjectId> {
|
|
self.level
|
|
.board()
|
|
.at(x, y)
|
|
.map(|o| o.id)
|
|
.find(|id| self.side.entities.contains_key(id))
|
|
}
|
|
|
|
// ---- What the player does ----
|
|
|
|
/// Add an object or a piece of text, then reparse.
|
|
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
|
|
let id = self.level.place(kind, x, y);
|
|
self.side.grid_changed(&mut self.level);
|
|
id
|
|
}
|
|
|
|
/// One whole turn (Module 9); what it destroys goes through seam 2.
|
|
pub fn step(&mut self, dir: Dir) {
|
|
let side = &mut self.side;
|
|
self.level
|
|
.step_with(dir, &mut |level, ids| side.destroy(level, ids));
|
|
self.side.grid_changed(&mut self.level);
|
|
}
|
|
|
|
/// Movement only (Module 8), then a reparse.
|
|
pub fn move_dir(&mut self, dir: Dir) {
|
|
let side = &mut self.side;
|
|
self.level
|
|
.move_you_with(dir, &mut |level, ids| side.destroy(level, ids));
|
|
self.side.grid_changed(&mut self.level);
|
|
}
|
|
|
|
/// Why `cast(card, target)` can't be done now, or `Ok` if it can: the card must be a
|
|
/// spell in player 0's hand, and a spell that hits a chosen minion needs a target
|
|
/// minion on the grid. Nothing changes either way.
|
|
pub fn check_cast(&self, card: &str, target: Option<ObjectId>) -> Result<(), String> {
|
|
self.side.check_hand(card, CardType::Spell)?;
|
|
let chooses = self.side.world.card(card).on_cast.iter().any(|e| {
|
|
matches!(
|
|
e,
|
|
Effect::DealDamage {
|
|
target: Target::Chosen,
|
|
..
|
|
} | Effect::GainAttack {
|
|
target: Target::Chosen,
|
|
..
|
|
} | Effect::Destroy {
|
|
target: Target::Chosen
|
|
}
|
|
)
|
|
});
|
|
match target {
|
|
None if chooses => Err(format!("\"{card}\" needs a target minion")),
|
|
None => Ok(()),
|
|
Some(id) => {
|
|
// Every id in `entities` was issued by the board, so `find` may ask about it.
|
|
let card = self.side.entities.contains_key(&id);
|
|
if card && self.level.board().find(id).is_some() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("{id} is not a minion on the grid"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why `play(card, x, y)` can't be done now, or `Ok` if it can.
|
|
pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> {
|
|
self.side.check_hand(card, CardType::Minion)?;
|
|
if self.side.world.card(card).def.noun.is_none() {
|
|
return Err(format!(
|
|
"\"{card}\" has no noun: it can't stand on the grid"
|
|
));
|
|
}
|
|
if !self.level.board().in_bounds(x, y) {
|
|
return Err(format!("({x}, {y}) is off the grid"));
|
|
}
|
|
if self.level.board().at(x, y).next().is_some() {
|
|
return Err(format!("({x}, {y}) is not empty"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
|
|
/// Panics if `check_cast` says no.
|
|
pub fn cast(&mut self, card: &str, target: Option<ObjectId>) {
|
|
if let Err(problem) = self.check_cast(card, target) {
|
|
panic!("{problem}");
|
|
}
|
|
let in_hand = self.side.in_hand(card).expect("checked");
|
|
self.side.world.move_to(in_hand, Zone::Graveyard);
|
|
let target = target.map(|id| self.side.entity(id));
|
|
let action = Action::Cast {
|
|
card: card.to_string(),
|
|
player: 0,
|
|
target,
|
|
};
|
|
self.side.sequence(&mut self.level, action);
|
|
}
|
|
|
|
/// Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence.
|
|
/// Returns its new id. Panics if `check_play` says no.
|
|
pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId {
|
|
if let Err(problem) = self.check_play(card, x, y) {
|
|
panic!("{problem}");
|
|
}
|
|
let in_hand = self.side.in_hand(card).expect("checked");
|
|
let kind = self.side.noun_of(card);
|
|
let id = self.level.board_mut().place(kind, x, y);
|
|
self.side.link(in_hand, id);
|
|
self.side
|
|
.sequence(&mut self.level, Action::Play { id: in_hand });
|
|
id
|
|
}
|
|
}
|
|
|
|
impl CardSide {
|
|
fn entity(&self, id: ObjectId) -> EntityId {
|
|
*self
|
|
.entities
|
|
.get(&id)
|
|
.unwrap_or_else(|| panic!("{id} is not a card"))
|
|
}
|
|
|
|
fn link(&mut self, entity: EntityId, id: ObjectId) {
|
|
self.ids.insert(entity, id);
|
|
self.entities.insert(id, entity);
|
|
}
|
|
|
|
/// Give every entity that has no id one that no object will have.
|
|
fn link_the_rest(&mut self, level: &mut Level) {
|
|
for entity in 0..self.world.entity_count() {
|
|
if !self.ids.contains_key(&entity) {
|
|
let id = level.board_mut().reserve_id();
|
|
self.link(entity, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn noun_of(&self, card: &str) -> Kind {
|
|
let def = &self.world.card(card).def;
|
|
match (&def.noun, def.card_type) {
|
|
(Some(noun), CardType::Minion) => Kind::parse(&noun.to_lowercase()),
|
|
_ => panic!("{card:?} is not a minion with a noun"),
|
|
}
|
|
}
|
|
|
|
fn in_hand(&self, card: &str) -> Option<EntityId> {
|
|
self.world
|
|
.zone(0, Zone::Hand)
|
|
.iter()
|
|
.copied()
|
|
.find(|&id| self.world.card_name(id) == card)
|
|
}
|
|
|
|
fn check_hand(&self, card: &str, card_type: CardType) -> Result<(), String> {
|
|
if self.in_hand(card).is_none() {
|
|
return Err(format!("\"{card}\" is not in the hand"));
|
|
}
|
|
if self.world.card(card).def.card_type != card_type {
|
|
let kind = match card_type {
|
|
CardType::Minion => "minion",
|
|
CardType::Spell => "spell",
|
|
};
|
|
return Err(format!("\"{card}\" is not a {kind}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Seam 2. Objects that aren't cards leave the board. Minions are marked pending
|
|
/// destroy, in one Sequence, under the rules as they stand now.
|
|
fn destroy(&mut self, level: &mut Level, ids: &[ObjectId]) {
|
|
let mut seen = HashSet::new();
|
|
let mut minions = Vec::new();
|
|
for &id in ids {
|
|
if !seen.insert(id) || level.board().find(id).is_none() {
|
|
continue;
|
|
}
|
|
match self.entities.get(&id) {
|
|
Some(&entity) => minions.push(entity),
|
|
None => level.board_mut().remove(id),
|
|
}
|
|
}
|
|
if minions.is_empty() {
|
|
return;
|
|
}
|
|
self.sync_rules(level);
|
|
self.sequence(level, Action::Destroy { targets: minions });
|
|
}
|
|
|
|
/// Run one Sequence in Part I, then seams 3 and 4: apply its grid changes and reparse.
|
|
fn sequence(&mut self, level: &mut Level, action: Action) {
|
|
let from = self.world.log().len();
|
|
run_sequence(&mut self.world, action);
|
|
let events = self.world.log()[from..].to_vec();
|
|
// Text first, while every minion that wrote it is still on its tile. A minion
|
|
// with no id (summoned by this Sequence) is not on the grid yet: nowhere to write.
|
|
for event in &events {
|
|
if let Event::TextSpawned { source, word } = event {
|
|
let tile = self
|
|
.ids
|
|
.get(source)
|
|
.and_then(|&id| level.board().find(id))
|
|
.map(|o| (o.x, o.y));
|
|
if let Some((x, y)) = tile {
|
|
let text = Kind::Text { word: word.clone() };
|
|
level.board_mut().place(text, x, y);
|
|
}
|
|
}
|
|
}
|
|
// Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are
|
|
// still on the grid here, so a Deathrattle's summoner is found where it died; a
|
|
// minion summoned earlier in the list is already placed, so it can be a summoner too.
|
|
for event in &events {
|
|
let Event::Summoned { id, by: Some(by) } = *event else {
|
|
continue;
|
|
};
|
|
let card = self.world.card_name(id).to_string();
|
|
if self.ids.contains_key(&id) || self.world.card(&card).def.noun.is_none() {
|
|
continue;
|
|
}
|
|
let tile = self
|
|
.ids
|
|
.get(&by)
|
|
.and_then(|&object| level.board().find(object))
|
|
.map(|o| (o.x, o.y));
|
|
if let Some((x, y)) = tile {
|
|
let object = level.board_mut().place(self.noun_of(&card), x, y);
|
|
self.link(id, object);
|
|
}
|
|
}
|
|
for event in &events {
|
|
if let Event::Destroyed { id, .. } = event {
|
|
if let Some(&object) = self.ids.get(id) {
|
|
level.board_mut().remove(object);
|
|
}
|
|
}
|
|
}
|
|
self.link_the_rest(level);
|
|
self.grid_changed(level);
|
|
}
|
|
|
|
/// The grid changed: reparse it, then seam 1.
|
|
fn grid_changed(&mut self, level: &mut Level) {
|
|
level.reparse();
|
|
self.sync_rules(level);
|
|
}
|
|
|
|
/// Seam 1: compile the rules in force into auras for Part I, then run an Aura Update.
|
|
fn sync_rules(&mut self, level: &Level) {
|
|
let auras = rule_auras(level.active_rules(), &self.seam_words);
|
|
self.world.set_rule_auras(auras);
|
|
aura_update_other(&mut self.world);
|
|
}
|
|
}
|