feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop)

Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks,
the cumulative reference (Module 11's plus combat) and the drop. The Damage
action carries per-target hits with a priority; react stable-sorts its Queue by
priority before order of play, so the triggers answering the defender's Damage
Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack
because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari
Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness
contract, catalog, research notes and earlier lessons' forward pointers follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 13:34:20 +01:00
1 parent c8fff05ccb
commit fb9d0a4eb4
89 files changed
+11852 -6

No files matched your search

@@ -0,0 +1,174 @@
//! Assemble: "Get a random minion. Change half its text."
//!
//! Card text is a list of clauses, so changing half of it is list surgery: the base
//! card's `text[..split]` (top) or `text[split..]` (bottom) is replaced by the same
//! half of a donor card. Name, cost and stats stay the base's. This works only
//! because a card's behaviour is its data: the new card needs no new code.
//!
//! Hearthstone's wiki doesn't define "half". The course's `split` field is its
//! definition: each card's data says where its top half ends.
use std::fmt;
use super::card::{well_formed, CardDef, PoolFilter};
use super::world::{EntityId, PlayerId, World, Zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Half {
Top,
Bottom,
}
impl Half {
pub const BOTH: [Half; 2] = [Half::Top, Half::Bottom];
pub fn label(self) -> &'static str {
match self {
Half::Top => "top",
Half::Bottom => "bottom",
}
}
}
/// Why an Assemble was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssembleFailure {
/// The new text would equal the base's.
Unchanged,
/// The new text would not be well-formed (see `well_formed`).
IllFormed,
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
pub reason: AssembleFailure,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let result = match self.reason {
AssembleFailure::Unchanged => "leaves its text unchanged",
AssembleFailure::IllFormed => "gives ill-formed text",
};
write!(
f,
"assembling {:?} with the {} half of {:?} {result}",
self.base,
self.half.label(),
self.donor
)
}
}
impl std::error::Error for AssembleError {}
/// The base card with `half` of its text replaced by the same half of `donor`.
/// It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
let (base_top, base_bottom) = base.text.split_at(base.split);
let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
let (top, bottom) = match half {
Half::Top => (donor_top, base_bottom),
Half::Bottom => (base_top, donor_bottom),
};
let text = [top, bottom].concat();
let refuse = |reason| AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
reason,
};
if text == base.text {
return Err(refuse(AssembleFailure::Unchanged));
}
if !well_formed(&text, base.card_type) {
return Err(refuse(AssembleFailure::IllFormed));
}
Ok(CardDef {
name: format!("{} (Assembled)", base.name),
split: top.len(),
text,
..base.clone()
})
}
/// Register an assembled card and return its name. If a different card already has
/// the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
let stem = def.name.clone();
let mut n = 1;
while world.has_card(&def.name) && world.card(&def.name).def != def {
n += 1;
def.name = format!("{} {n})", stem.trim_end_matches(')'));
}
let name = def.name.clone();
world.register_assembled(def);
name
}
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
/// (filtered), a donor, and the half to replace. Only combinations that change the
/// base's text and leave it well-formed are offered: a donor is offered if one of its
/// halves gives such a text, and a base if it has such a donor. The Assembled minion
/// goes to the owner's hand. Real Hearthstone picks the base at random and offers two
/// donors; here every choice is the chooser's.
pub fn assemble_from_pool(
world: &mut World,
player: PlayerId,
filter: &PoolFilter,
) -> Option<EntityId> {
// Each pool card, and whether the filter lets it be the base.
let pool: Vec<(CardDef, bool)> = world
.pool()
.into_iter()
.map(|c| {
(
c.def.clone(),
filter.trigger.map_or(true, |on| c.has_trigger(on)),
)
})
.collect();
// Does assembling `base` with `donor`'s `half` give a new, well-formed text?
let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok();
let donors_for = |base: &CardDef| -> Vec<String> {
pool.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h)))
.map(|d| d.name.clone())
.collect()
};
let bases: Vec<String> = pool
.iter()
.filter(|(d, allowed)| *allowed && !donors_for(d).is_empty())
.map(|(d, _)| d.name.clone())
.collect();
if bases.is_empty() {
return None;
}
let base_name = world.choose("base", &bases);
let base = world.card(&base_name).def.clone();
let donor_name = world.choose("donor", &donors_for(&base));
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&base, &donor, h))
.map(|h| h.label().to_string())
.collect();
let half = world.choose("half", &halves);
let half = if half == "top" {
Half::Top
} else {
Half::Bottom
};
let def = assemble(&base, &donor, half)
.expect("only halves that give a new, well-formed text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -0,0 +1,84 @@
//! Auras, the one routine that applies trigger multipliers, and the lookup for
//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
//! something. It takes a snapshot of the auras in force at defined points (an Aura
//! Update) and every rule reads the snapshot.
//!
//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
//! Health/Attack auras before the Death Creation Step, and one for every other aura
//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
//! Summoning a minion also runs an Aura Update.
//!
//! Module 10: auras can come from outside the cards. SpellGrid compiles rules on its
//! grid into auras (`World::set_rule_auras`); they join the snapshot at the next Aura
//! Update, beside the minions' own, and go through the same routine. A rule's
//! multiplier stacks with a card's by the same `stacking` field. A cap (`IS NOT`)
//! limits the total, whatever multiplies it.
use super::card::{Aura, Stacking, TriggerOn};
use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in
/// play, and the auras from outside the cards, which apply to both players.
pub fn aura_update_other(world: &mut World) {
let mut active = Vec::new();
for id in world.minions_in_play() {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
source: Some(id),
aura,
});
}
}
for &aura in world.rule_auras() {
for owner in 0..2 {
active.push(ActiveAura {
owner,
source: None,
aura,
});
}
}
world.set_active_auras(active);
}
/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
pub fn redirect_for(world: &World, player: PlayerId) -> Option<EntityId> {
world
.active_auras()
.iter()
.find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
.and_then(|a| a.source)
}
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations. A cap
/// (`Aura::TriggerCap`) then limits the total to its `times`.
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
let mut max = 1;
let mut extra = 0;
let mut cap = u32::MAX;
for active in world.active_auras().iter().filter(|a| a.owner == owner) {
match active.aura {
Aura::TriggerCap { trigger, times } if trigger == on => cap = cap.min(times),
Aura::TriggerMultiplier {
trigger,
times,
stacking,
} if trigger == on => match stacking {
Stacking::Max => max = max.max(times),
Stacking::Additive => extra += times - 1,
},
_ => {}
}
}
cap.min(max + extra)
}
@@ -0,0 +1,348 @@
//! 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 (Module 12: `end_turn`).
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 },
/// Damage that would be dealt to the aura owner's hero is dealt to the aura's
/// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
DamageRedirect { from: RedirectFrom, to: RedirectTo },
}
/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RedirectFrom {
FriendlyHero,
}
/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum RedirectTo {
#[serde(rename = "self")]
This,
}
/// 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>,
/// Keywords, such as `"taunt"` (Module 12: combat reads it).
pub keywords: Vec<String>,
/// 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 keywords = 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;
}
// Module 12: combat reads Taunt (`combat.rs`).
Clause::Keyword { keyword } => {
keywords.push(keyword.clone());
open_trigger = false;
}
}
}
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
Card {
def,
triggers,
auras,
keywords,
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)
}
}
/// Does `text` mean what it says? For a minion, every effect clause belongs to a
/// trigger (none comes before the first trigger, or after an aura or keyword), and
/// every trigger has at least one effect before the next trigger, aura, keyword or
/// the end. The rule is for minions (Assemble makes only minions): a spell passes.
pub fn well_formed(text: &[Clause], card_type: CardType) -> bool {
if card_type != CardType::Minion {
return true;
}
// The trigger whose effects are being listed: none, one with no effect yet, or one with some.
#[derive(PartialEq)]
enum Open {
None,
Empty,
Filled,
}
let mut open = Open::None;
for clause in text {
match clause {
Clause::Effect { .. } if open == Open::None => return false,
Clause::Effect { .. } => open = Open::Filled,
_ if open == Open::Empty => return false,
Clause::Trigger { .. } => open = Open::Empty,
Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None,
}
}
open != Open::Empty
}
/// 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
}
}
@@ -0,0 +1,49 @@
//! Combat (Module 12): who may attack whom.
//!
//! An attack is one Sequence (`Action::Attack` in `queue.rs`): both characters deal their
//! Attack to each other at once, as one Damage action with two targets, and the
//! defender's Damage Event queues its triggers first. This file holds only the
//! legality rules, so the harness can ask them without attacking.
use super::world::{EntityId, World, Zone};
/// The keyword that makes a minion the only thing its enemies may attack.
pub const TAUNT: &str = "taunt";
/// Does `id`, a minion in play, have Taunt?
pub fn has_taunt(world: &World, id: EntityId) -> bool {
world
.card(world.card_name(id))
.keywords
.iter()
.any(|k| k == TAUNT)
}
/// Why `attacker` may not attack `defender` now, or `None` if it may. The rules, in
/// the order they are checked: the attacker is a minion in play; the defender is an
/// enemy minion in play or the enemy hero; the attacker has some Attack; and while the
/// defender's side has a minion with Taunt, only such a minion may be attacked.
pub fn attack_error(world: &World, attacker: EntityId, defender: EntityId) -> Option<String> {
if world.is_hero(attacker) || world.zone_of(attacker) != Zone::Board {
return Some("the attacker is not a minion in play".to_string());
}
let enemy = 1 - world.owner_of(attacker);
let enemy_character = world.owner_of(defender) == enemy
&& (world.is_hero(defender) || world.zone_of(defender) == Zone::Board);
if !enemy_character {
return Some("the defender is not an enemy character in play".to_string());
}
if world.attack(attacker) <= 0 {
return Some("a minion with 0 Attack cannot attack".to_string());
}
let taunts: Vec<EntityId> = world
.zone(enemy, Zone::Board)
.iter()
.copied()
.filter(|&id| has_taunt(world, id))
.collect();
if !taunts.is_empty() && !taunts.contains(&defender) {
return Some("a minion with Taunt is in the way".to_string());
}
None
}
@@ -0,0 +1,41 @@
//! The Death Creation Step: the one place minions leave play.
//!
//! Damage and destroy effects only mark a minion (0 or less Health, or pending
//! destroy). It stays in play, and keeps triggering, until the outermost Phase
//! ends. Then this step removes every marked minion at once.
use super::event::Event;
use super::world::{EntityId, World, Zone};
/// Remove every mortally wounded or pending-destroy minion from play, simultaneously.
/// Returns their Death Events in order of play; empty if nothing died.
pub fn death_creation_step(world: &mut World) -> Vec<Event> {
// Every neighbour is read before anything moves: removal is simultaneous. Each Death
// Event remembers where its minion stood as a handle, the minion that stays to its
// right, not as a board index: an index goes stale as soon as a minion further left
// leaves play in the same step (Module 1: hold handles, not indexes).
let mut deaths = Vec::new();
for id in world.minions_in_play() {
if world.is_doomed(id) {
let before = survivor_to_the_right(world, id);
deaths.push(Event::Destroyed { id, before });
}
}
for death in &deaths {
if let Event::Destroyed { id, before } = *death {
world.kill(id, before);
}
}
deaths
}
/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any.
fn survivor_to_the_right(world: &World, id: EntityId) -> Option<EntityId> {
world
.zone(world.owner_of(id), Zone::Board)
.iter()
.copied()
.skip_while(|&other| other != id)
.skip(1)
.find(|&other| !world.is_doomed(other))
}
@@ -0,0 +1,97 @@
//! The interpreter: turns one effect, as data, into engine actions.
//!
//! This is the only place that knows what an effect *means*. A card's text is a
//! small tree (clauses holding effects holding targets); the engine walks it. The
//! effect vocabulary is closed: a card can only do what this match can do, which is
//! what makes a data card safe to load.
use super::card::{Effect, Target};
use super::queue::{hits, Action, Ctx};
use super::world::EntityId;
/// The actions one effect performs, in order, given where and for whom it runs.
pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
let owner = ctx.owner;
match effect {
Effect::DealDamage { target, amount } => vec![Action::Damage {
hits: hits(&targets(*target, ctx), *amount),
}],
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
// Several tokens stand left to right in summon order: each goes right of the one before.
Effect::Summon { card, count } => {
let at = summon_position(ctx);
(0..*count)
.map(|i| Action::Summon {
card: card.clone(),
player: owner,
at: at.map(|at| at + i as usize),
})
.collect()
}
Effect::GainArmor { amount } => vec![Action::GainArmor {
player: owner,
amount: *amount,
}],
Effect::GainAttack { target, amount } => targets(*target, ctx)
.into_iter()
.map(|id| Action::GainAttack {
id,
amount: *amount,
})
.collect(),
Effect::Destroy { target } => vec![Action::Destroy {
targets: targets(*target, ctx),
}],
Effect::Assemble { filter } => vec![Action::Assemble {
player: owner,
filter: filter.clone(),
}],
Effect::BuffHand { attack, health } => ctx
.world
.minions_in_hand(owner)
.into_iter()
.map(|id| Action::Buff {
id,
attack: *attack,
health: *health,
})
.collect(),
Effect::SpawnText { word, .. } => vec![Action::SpawnText {
source: ctx.minion(),
word: word.clone(),
}],
}
}
/// The entities a target names, from where the effect runs.
fn targets(target: Target, ctx: &Ctx) -> Vec<EntityId> {
let world = ctx.world;
let others = |ids: Vec<EntityId>| -> Vec<EntityId> {
ids.into_iter()
.filter(|&id| Some(id) != ctx.source)
.collect()
};
match target {
Target::This => vec![ctx.minion()],
Target::AllMinions => world.minions_in_play(),
Target::AllOtherMinions => others(world.minions_in_play()),
Target::AllOtherCharacters => {
let mut ids = vec![world.hero(0), world.hero(1)];
ids.extend(world.minions_in_play());
others(ids)
}
Target::EnemyHero => vec![ctx.enemy_hero()],
Target::Chosen => ctx.target.into_iter().collect(),
}
}
/// Where a summon goes: to the right of a source still on the board; where a dead
/// source stood (left of the minion its Death Event remembers); at the right end for a spell.
fn summon_position(ctx: &Ctx) -> Option<usize> {
match ctx.source.and_then(|s| ctx.world.board_index(s)) {
Some(index) => Some(index + 1),
None => ctx.position,
}
}
@@ -0,0 +1,76 @@
//! What happened, in order. The engine records an event each time it changes the game.
//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`).
use super::card::TriggerOn;
use super::world::{EntityId, PlayerId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
/// `id` entered the board. `by` is the minion whose triggered ability put it there (the
/// trigger resolving when it entered), or `None` for a spell, a play or a summon from
/// outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log
/// doesn't show `by`.
Summoned {
id: EntityId,
by: Option<EntityId>,
},
Damaged {
target: EntityId,
amount: i32,
},
Triggered {
source: EntityId,
on: TriggerOn,
},
Drew {
player: PlayerId,
id: EntityId,
},
Fatigue {
player: PlayerId,
},
ArmorGained {
player: PlayerId,
amount: i32,
},
/// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
/// shows the full amount.
ArmorLost {
player: PlayerId,
amount: i32,
},
StatsChanged {
id: EntityId,
attack: i32,
health: i32,
},
SpellCast {
card: String,
},
/// A Death Event: the Death Creation Step moved `id` from play to the graveyard.
/// `before` is the nearest minion to its right that did not die in the same step, if any:
/// its Deathrattle summons immediately left of that minion, or at the right end. It is a
/// handle because a board index goes stale when a minion further left dies in the same step.
Destroyed {
id: EntityId,
before: Option<EntityId>,
},
DeathPhase {
round: u32,
},
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
Played {
id: EntityId,
},
/// Module 12: `player`'s turn ended. Their minions' end of turn triggers answer this.
TurnEnded {
player: PlayerId,
},
/// Module 10: `source`'s effect asked for `word` to be written on its grid tile.
/// Part I has no grid: it only records the request. SpellGrid reads it when the
/// Sequence ends.
TextSpawned {
source: EntityId,
word: String,
},
}
@@ -0,0 +1,68 @@
//! Engine core: generic rules machinery. Never name a specific card here.
//!
//! Module 1: every card in the game is an entity addressed by an id handle.
//! The engine knows which zone each entity is in, who owns it, and when it
//! entered play.
//!
//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
//! action queue that resolves a Sequence depth-first, recording an event log.
//!
//! Module 3: deferred death. Damage and destroy effects only mark minions; the
//! Death Creation Step removes them when the outermost Phase ends, and Death
//! Phases resolve their Deathrattles until no new deaths occur.
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
//! minion from hand, and Battlecries. A second interceptor, replacement: the
//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
//! builds a new card by swapping half of one card's text for half of another's, and
//! offers only results that are well-formed. A Sequence is bounded: a trigger never
//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Module 12: combat (`combat`: who may attack whom; Taunt) and the end of a turn.
//! A Damage action deals each target its own amount, and a Queue orders its entries by
//! priority before order of play, so the defender's triggers go first.
//!
//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game,
//! beside the first.
//! Module 6: the grid, the lexer and the parser.
//! Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force.
//! Module 8: movement, planned in full and then committed; push chains.
//! Module 9: the turn, as a list of phases; hazards; winning.
//! Module 11: conditions (`X ON Z IS P`), checked for each object when a rule is used.
//!
//! Part III lives in `spellgrid`, the only module that knows both engines.
//! Module 10: SpellGrid. Part I gains auras from outside the cards
//! (`World::set_rule_auras`), a cap on activations, the `spawnText` effect (recorded as a
//! `TextSpawned` event) and the end-of-Phase steps as a list (`END_OF_PHASE`); Part II
//! gains a destroy function.
mod assemble;
mod aura;
mod card;
mod combat;
mod death;
mod effect;
mod event;
pub mod puzzle;
mod queue;
mod spellgrid;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half};
pub use aura::aura_update_other;
pub use card::{
well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter,
RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use combat::{attack_error, TAUNT};
pub use event::Event;
pub use queue::{
hits, run_sequence, summon, Action, Ctx, DamageStep, EndOfPhaseStep, Hit, Priority,
DAMAGE_STEPS, DEATH_PHASE_LIMIT, DEFENDER, END_OF_PHASE, NORMAL,
};
pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -0,0 +1,21 @@
//! Conditions (Module 11): `X ON Z IS P` gives an X the property P only while it is on a Z.
//!
//! The parser reads a condition into the rule (`Rule::conditions`); the engine never
//! decides it there. A condition is evaluated for one object, against the board as
//! it is, every time a rule is about to be used: by `Level::has_property` (and so by
//! movement, the hazards and the win check), and by conversion. What a condition
//! word means is a `ConditionCheck` that content provides, registered by word, as
//! movement responses and hazards are. The engine knows only that a word with role
//! `Condition` may stand between the subjects and `IS`.
use std::collections::HashMap;
use super::grid::ObjectId;
use super::level::Level;
/// Content's meaning for one condition word: does the condition, with these nouns
/// (in the order they are written), hold for object `id` on the board as it is now?
pub type ConditionCheck = fn(&Level, ObjectId, &[String]) -> bool;
/// Content's condition checks, by condition word: `{"ON": on}`.
pub type ConditionChecks = HashMap<String, ConditionCheck>;
@@ -0,0 +1,175 @@
//! The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
//! objects may share a tile.
use std::collections::BTreeMap;
use std::fmt;
use serde::Deserialize;
pub type ObjectId = u32;
/// A level in the course's level format (`course/schema/level.schema.json`).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct LevelDef {
pub width: i32,
pub height: i32,
#[serde(default)]
pub objects: Vec<LevelObject>,
/// Module 10, read only by SpellGrid: minions in play, each on its tile.
#[serde(default)]
pub cards: Vec<LevelCard>,
/// Module 10, read only by SpellGrid: the cards in player 0's hand.
#[serde(default)]
pub hand: Vec<String>,
}
/// A minion card on the grid (Module 10).
#[derive(Debug, Clone, Deserialize)]
pub struct LevelCard {
pub card: String,
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LevelObject {
pub kind: String,
pub x: i32,
pub y: i32,
}
/// What an object is: a thing named by a noun (kind `"<noun>"`, the noun in lower case),
/// or a piece of text carrying one word (kind `"text:<WORD>"`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Thing { noun: String },
Text { word: String },
}
impl Kind {
const TEXT_PREFIX: &'static str = "text:";
pub fn parse(kind: &str) -> Kind {
match kind.strip_prefix(Self::TEXT_PREFIX) {
Some(word) => Kind::Text {
word: word.to_uppercase(),
},
None => Kind::Thing {
noun: kind.to_uppercase(),
},
}
}
}
/// The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`.
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Kind::Thing { noun } => write!(f, "{}", noun.to_lowercase()),
Kind::Text { word } => write!(f, "{}{word}", Self::TEXT_PREFIX),
}
}
}
#[derive(Debug, Clone)]
pub struct Object {
pub id: ObjectId,
pub kind: Kind,
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone, Default)]
pub struct Board {
pub width: i32,
pub height: i32,
objects: BTreeMap<ObjectId, Object>,
next_id: ObjectId,
}
impl Board {
pub fn new(width: i32, height: i32) -> Board {
Board {
width,
height,
..Default::default()
}
}
pub fn in_bounds(&self, x: i32, y: i32) -> bool {
(0..self.width).contains(&x) && (0..self.height).contains(&y)
}
/// Add an object; ids start at 1 and are never reused. Panics off the board.
pub fn place(&mut self, kind: Kind, x: i32, y: i32) -> ObjectId {
assert!(
self.in_bounds(x, y),
"({x}, {y}) is outside the {}x{} level",
self.width,
self.height
);
self.next_id += 1;
let id = self.next_id;
self.objects.insert(id, Object { id, kind, x, y });
id
}
/// An id that no object will ever have (Module 10): SpellGrid gives one to each
/// card that isn't on the grid, so cards and objects share one id space.
pub fn reserve_id(&mut self) -> ObjectId {
self.next_id += 1;
self.next_id
}
/// The object `id`. Panics if there is none.
pub fn get(&self, id: ObjectId) -> &Object {
self.objects
.get(&id)
.unwrap_or_else(|| panic!("no object {id}"))
}
/// The object `id`, or `None` once it has been removed. Panics on an id this
/// board never issued.
pub fn find(&self, id: ObjectId) -> Option<&Object> {
assert!((1..=self.next_id).contains(&id), "no object {id}");
self.objects.get(&id)
}
/// Take object `id` off the board for good.
pub fn remove(&mut self, id: ObjectId) {
self.objects.remove(&id);
}
/// Move object `id` by `(dx, dy)`. Panics if it would leave the board.
pub fn shift(&mut self, id: ObjectId, (dx, dy): (i32, i32)) {
let (width, height) = (self.width, self.height);
let o = self
.objects
.get_mut(&id)
.unwrap_or_else(|| panic!("no object {id}"));
let (x, y) = (o.x + dx, o.y + dy);
assert!(
(0..width).contains(&x) && (0..height).contains(&y),
"({x}, {y}) is outside the {width}x{height} level"
);
(o.x, o.y) = (x, y);
}
/// Make object `id` a `kind`, where it stands. Panics if there is no such object.
pub fn set_kind(&mut self, id: ObjectId, kind: Kind) {
self.objects
.get_mut(&id)
.unwrap_or_else(|| panic!("no object {id}"))
.kind = kind;
}
/// The objects on tile (x, y), oldest first.
pub fn at(&self, x: i32, y: i32) -> impl Iterator<Item = &Object> {
self.objects().filter(move |o| (o.x, o.y) == (x, y))
}
/// Every object, oldest first.
pub fn objects(&self) -> impl Iterator<Item = &Object> {
self.objects.values()
}
}
@@ -0,0 +1,382 @@
//! A level in play: the board, the words content provides, and the rules compiled
//! from them. Every change to the board recompiles the rules from scratch: parse,
//! then resolve. Every question about what an object is goes through `has_property`.
//!
//! A move (Module 8) is planned for each `YOU` object, then committed; the rules are
//! reparsed once, after all of them.
//!
//! A turn (Module 9) runs the phases of `TURN` in order: movement, parse,
//! conversion, parse, content's hazards, the win check, parse.
//!
//! Undo (Module 9): before each move or turn the level saves the board (every
//! object's id, kind and position, oldest first, and the id counter) and `won`.
//! The rules are not saved: undo restores the board and reparses.
//!
//! Module 10: what happens to the objects a turn destroys is a destroy function the
//! caller may pass in (`step_with`, `move_you_with`). The default, `remove_all`,
//! removes them. SpellGrid passes one that sends minions to Part I's Death Creation
//! Step instead. SpellGrid doesn't offer undo: Part I has state the level's
//! snapshot doesn't cover.
//!
//! Module 11: a rule may carry conditions (`X ON Z IS P`). They are not
//! decided when the rules are compiled. Every question about an object
//! (`has_property`, conversion) asks content's `ConditionCheck`s against the board
//! as it is at that moment, and uses only the rules whose conditions hold for that
//! object.
use std::collections::BTreeSet;
use super::condition::ConditionChecks;
use super::grid::{Board, Kind, LevelDef, ObjectId};
use super::movement::{Dir, Plan, Planner, Response};
use super::parse::{parse, Rule};
use super::resolve::resolve;
use super::turn::{Hazard, Phase, TURN};
use super::word::{Lexicon, Role, WordDef};
#[derive(Debug, Clone)]
pub struct Level {
board: Board,
lexicon: Lexicon,
responses: Vec<Response>,
hazards: Vec<Hazard>,
conditions: ConditionChecks,
rules: Vec<Rule>,
active: Vec<Rule>,
won: bool,
/// What `undo` puts back: one entry per move or turn, the latest last (Module 9).
history: Vec<Snapshot>,
}
/// The level before a move or a turn: the whole board (its objects, oldest first,
/// and its id counter) and `won`.
#[derive(Debug, Clone)]
struct Snapshot {
board: Board,
won: bool,
}
/// The property that makes an object move when the player moves.
pub const YOU: &str = "YOU";
/// The property that wins the level when a `YOU` object is on it, or is it.
pub const WIN: &str = "WIN";
/// The property that puts an object on the other float level: only objects on
/// the same level share a tile for hazards and for winning.
pub const FLOAT: &str = "FLOAT";
/// What to do with objects a move or a turn destroys (Module 10). It may change the
/// board, and reparse, before it returns. An id may appear more than once.
pub type Destroy<'a> = dyn FnMut(&mut Level, &[ObjectId]) + 'a;
/// The default destroy function: take every object off the board.
pub fn remove_all(level: &mut Level, ids: &[ObjectId]) {
for &id in ids {
level.board.remove(id);
}
}
impl Level {
/// A level from its definition, the words content provides, content's movement
/// responses and hazards, each in the order they are to run, and content's
/// condition checks, by word. Panics if a word with role `Condition` has no check.
pub fn new(
def: &LevelDef,
words: &[WordDef],
responses: Vec<Response>,
hazards: Vec<Hazard>,
conditions: ConditionChecks,
) -> Level {
for w in words {
if w.role == Role::Condition && !conditions.contains_key(&w.name.to_uppercase()) {
panic!("the condition word {} has no condition check", w.name);
}
}
let mut level = Level {
board: Board::new(def.width, def.height),
lexicon: Lexicon::new(words),
responses,
hazards,
conditions,
rules: vec![],
active: vec![],
won: false,
history: vec![],
};
for o in &def.objects {
level.board.place(Kind::parse(&o.kind), o.x, o.y);
}
level.reparse();
level
}
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
let id = self.board.place(Kind::parse(kind), x, y);
self.history.clear();
self.reparse();
id
}
pub fn board(&self) -> &Board {
&self.board
}
/// The board, to change directly (Module 10: SpellGrid writes text and removes
/// minions). Nothing is reparsed until `reparse` is called.
pub fn board_mut(&mut self) -> &mut Board {
&mut self.board
}
/// The movement responses, in the order they run.
pub fn responses(&self) -> &[Response] {
&self.responses
}
/// Every rule the text spells, as parsed: negated rules and duplicates included.
pub fn rules(&self) -> &[Rule] {
&self.rules
}
/// The rules in force after the resolution pass.
pub fn active_rules(&self) -> &[Rule] {
&self.active
}
/// Does object `id` have `property`? Yes if an active rule that applies to it
/// (`applies_to`) gives it the property and no active negated rule that applies
/// to it takes it away. A destroyed object has no properties.
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
if self.board.find(id).is_none() {
return false;
}
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| r.complement == property && self.applies_to(r, id))
.collect();
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
}
/// Does the noun `word`, as the subject of a rule, name object `id`?
pub fn names(&self, word: &str, id: ObjectId) -> bool {
self.lexicon.names(word, &self.board.get(id).kind)
}
/// Does `rule` apply to object `id` now? Its subject names the object, and each
/// of its conditions holds for the object on the board as it is (Module 11).
fn applies_to(&self, rule: &Rule, id: ObjectId) -> bool {
self.names(&rule.subject, id)
&& rule
.conditions
.iter()
.all(|c| (self.conditions[&c.word])(self, id, &c.nouns))
}
/// The other objects on object `id`'s tile that are on its float level (both
/// `FLOAT` or both not), oldest first. Hazards and the win check ask this.
pub fn sharing(&self, id: ObjectId) -> Vec<ObjectId> {
let o = self.board.get(id);
let floats = self.has_property(id, FLOAT);
self.board
.at(o.x, o.y)
.filter(|other| other.id != id && self.has_property(other.id, FLOAT) == floats)
.map(|other| other.id)
.collect()
}
/// Has a turn's win check found a `YOU` object on a `WIN` one? Stays true.
pub fn won(&self) -> bool {
self.won
}
/// Is there no `YOU` object left?
pub fn lost(&self) -> bool {
!self.board.objects().any(|o| self.has_property(o.id, YOU))
}
/// Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
/// only: no conversion, hazard or win.
pub fn move_you(&mut self, dir: Dir) {
self.move_you_with(dir, &mut remove_all);
}
/// `move_you`, with destroyed objects going to `destroy` (Module 10).
pub fn move_you_with(&mut self, dir: Dir, destroy: &mut Destroy) {
self.save();
self.movement(dir, destroy);
self.reparse();
}
/// Play one turn: the phases of `TURN`, in order.
pub fn step(&mut self, dir: Dir) {
self.step_with(dir, &mut remove_all);
}
/// `step`, with destroyed objects going to `destroy` (Module 10).
pub fn step_with(&mut self, dir: Dir, destroy: &mut Destroy) {
self.save();
for phase in TURN {
match phase {
Phase::Movement => self.movement(dir, destroy),
Phase::Parse => self.reparse(),
Phase::Conversion => self.convert(destroy),
Phase::Hazards => self.run_hazards(destroy),
Phase::Win => self.check_win(),
}
}
}
/// Put the level back as it was before the last move or turn (Module 9), and
/// reparse. False, and no change, if there is none; `place` forgets them all.
pub fn undo(&mut self) -> bool {
let Some(last) = self.history.pop() else {
return false;
};
self.board = last.board;
self.won = last.won;
self.reparse();
true
}
fn save(&mut self) {
let snapshot = Snapshot {
board: self.board.clone(),
won: self.won,
};
self.history.push(snapshot);
}
/// The `YOU` objects move one at a time, the one furthest ahead in `dir` first
/// (oldest first on a tie); each one's move is planned in full, then committed,
/// before the next is planned. Every move uses the rules in force before the
/// first.
///
/// An object steps at most once in a movement phase. A `YOU` object that an
/// earlier move already carried along (pushed, or moved by any other response)
/// doesn't start a move of its own, and no later move can make it step again.
fn movement(&mut self, dir: Dir, destroy: &mut Destroy) {
let (dx, dy) = dir.delta();
let mut you: Vec<_> = self
.board
.objects()
.filter(|o| self.has_property(o.id, YOU))
.map(|o| (-(o.x * dx + o.y * dy), o.id))
.collect();
you.sort();
let mut stepped = BTreeSet::new();
for (_, id) in you {
if self.board.find(id).is_none() {
continue; // destroyed by an earlier move
}
if stepped.contains(&id) {
continue; // already moved, by an earlier move
}
let mut planner = Planner::new(self, dir, &stepped);
if planner.try_move(id) {
let plan = planner.into_plan();
self.commit(&plan, dir, destroy);
stepped.extend(plan.moving.difference(&plan.destroyed));
}
}
}
/// Destroy, then move what is still on the board (a destroy function may remove
/// more than it was given).
fn commit(&mut self, plan: &Plan, dir: Dir, destroy: &mut Destroy) {
if !plan.destroyed.is_empty() {
let ids: Vec<ObjectId> = plan.destroyed.iter().copied().collect();
destroy(self, &ids);
}
for &id in plan.moving.difference(&plan.destroyed) {
if self.board.find(id).is_some() {
self.board.shift(id, dir.delta());
}
}
}
/// Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y`
/// where it stands, keeping its id; with several `Y`s it becomes the first,
/// and each other `Y` is a new object on its tile. An `X IS X` naming the
/// object blocks this. An object named by `X IS NOT X` is deleted, and still
/// makes its `Y`s, as new objects: the negation cancelled `X IS X`.
///
/// Negation is settled per object, as in `has_property`: an object doesn't
/// become a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the
/// conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X).
///
/// Every object is decided from the board as movement left it, before any
/// changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. Only the rules that
/// apply to the object are used: their conditions are checked on that board too.
fn convert(&mut self, destroy: &mut Destroy) {
let mut changes = vec![];
for o in self.board.objects() {
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| self.applies_to(r, o.id))
.collect();
let deleted = about_it.iter().any(|r| r.negated && r.is_identity());
let locked = about_it.iter().any(|r| !r.negated && r.is_identity());
let mut into: Vec<Kind> = vec![];
if !locked {
for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) {
let negated = |n: &&Rule| n.negated && n.complement == r.complement;
if about_it.iter().any(negated) {
continue;
}
match self.lexicon.thing_named(&r.complement) {
Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind),
_ => {}
}
}
}
if deleted || !into.is_empty() {
changes.push((o.id, (o.x, o.y), deleted, into));
}
}
let mut deleted_ids = vec![];
for (id, (x, y), deleted, into) in changes {
let mut into = into.into_iter();
if deleted {
deleted_ids.push(id);
} else if let Some(first) = into.next() {
self.board.set_kind(id, first);
}
for kind in into {
self.board.place(kind, x, y);
}
}
if !deleted_ids.is_empty() {
destroy(self, &deleted_ids);
}
}
/// Run content's hazards in order. Each one's victims are destroyed together
/// before the next runs; no reparse until the turn's last phase.
fn run_hazards(&mut self, destroy: &mut Destroy) {
for i in 0..self.hazards.len() {
let hazard = self.hazards[i];
let victims = hazard(self);
if !victims.is_empty() {
destroy(self, &victims);
}
}
}
/// The level is won if any `YOU` object is `WIN`, or shares its tile with a
/// `WIN` object on its float level.
fn check_win(&mut self) {
let wins = |id: ObjectId| self.has_property(id, WIN);
let won = self
.board
.objects()
.filter(|o| self.has_property(o.id, YOU))
.any(|o| wins(o.id) || self.sharing(o.id).into_iter().any(wins));
self.won |= won;
}
/// Recompile the rules from the board: parse, then resolve.
pub fn reparse(&mut self) {
self.rules = parse(&self.board, &self.lexicon);
self.active = resolve(&self.rules);
}
}
@@ -0,0 +1,47 @@
//! Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is
//! compiled from the text on the board, and recompiled whenever the board changes.
//!
//! Module 6: the grid, the lexer (text objects to words with grammatical roles)
//! and the parser (words to rules). The engine knows the operators `IS`, `AND`,
//! `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word,
//! nouns included, is data handed in by content.
//!
//! Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the
//! identity lock) that turns the parsed rules into the rules in force. Objects are
//! asked about through `Level::has_property`, which knows which nouns name them.
//!
//! Module 8: movement. `Level::move_you` plans each `YOU` object's move in full
//! (`Planner`), then commits it, then reparses. What a property does when something
//! moves is a `Response` that content provides; the engine runs them in order.
//!
//! Module 9: the turn. `Level::step` runs the phases of `TURN` in order: movement,
//! parse, conversion, parse, hazards, win, parse. What a hazard property destroys
//! is a `Hazard` that content provides; the engine knows `YOU`, `WIN` and `FLOAT`.
//!
//! Module 10: `Level::step_with` and `Level::move_you_with` take a destroy function,
//! which decides what happens to the objects they destroy; `Board::reserve_id` hands
//! out an id no object has. The puzzle engine still knows nothing of cards: SpellGrid
//! (`../spellgrid.rs`) uses these.
//!
//! Module 11: conditions. A word with role `Condition` may stand between a rule's
//! subjects and `IS` (`X ON Z IS P`); the rule carries it (`Rule::conditions`).
//! What it means is a `ConditionCheck` content registers by word. `Level` asks the
//! checks every time it uses a rule, against the board as it is then.
mod condition;
mod grid;
mod level;
mod movement;
mod parse;
mod resolve;
mod turn;
mod word;
pub use condition::{ConditionCheck, ConditionChecks};
pub use grid::{Board, Kind, LevelCard, LevelDef, LevelObject, Object, ObjectId};
pub use level::{remove_all, Level, FLOAT, WIN, YOU};
pub use movement::{Dir, Plan, Planner, Response, Step};
pub use parse::{parse, Condition, Rule, BASE_RULES};
pub use resolve::resolve;
pub use turn::{Hazard, Phase, TURN};
pub use word::{lex, Lexicon, Role, Selects, Token, WordDef};
@@ -0,0 +1,144 @@
//! Movement: validate, then commit.
//!
//! A move is planned before anything on the board changes. The `Planner` asks
//! whether one object can step one tile; to answer, it runs every movement
//! response content provides for that step, and a response may ask the planner
//! to move other objects too (a push), or to destroy objects. If any response
//! says no, the planner forgets everything that step added to the plan, so a
//! failed push leaves no trace. Only a plan that succeeded is committed.
//!
//! The engine knows the board, its edge, and the order of the steps. What each
//! property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
//! response in content, run in the order content lists them.
use std::collections::BTreeSet;
use super::grid::ObjectId;
use super::level::Level;
/// One of the four directions a move can take.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
/// One tile in this direction, as `(dx, dy)`; `y` grows down.
pub fn delta(self) -> (i32, i32) {
match self {
Dir::Up => (0, -1),
Dir::Down => (0, 1),
Dir::Left => (-1, 0),
Dir::Right => (1, 0),
}
}
}
/// One object stepping one tile, from `from` to `to`, in direction `dir`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Step {
pub mover: ObjectId,
pub from: (i32, i32),
pub to: (i32, i32),
pub dir: Dir,
}
/// A movement response: content's rule for what happens when an object steps.
/// It returns `false` to block the step. It may plan other moves or destroy
/// objects through the planner.
pub type Response = fn(&mut Planner<'_>, Step) -> bool;
/// What a move will do: the objects that step one tile, and the objects destroyed.
/// A destroyed object doesn't step, even if it was pushed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Plan {
pub moving: BTreeSet<ObjectId>,
pub destroyed: BTreeSet<ObjectId>,
}
/// Plans one move on a board it only reads. `stepped` holds the objects that have
/// already stepped in this movement phase, in an earlier committed plan: an object
/// steps at most once per phase, so none of them steps again.
pub struct Planner<'a> {
level: &'a Level,
dir: Dir,
stepped: &'a BTreeSet<ObjectId>,
plan: Plan,
}
impl<'a> Planner<'a> {
pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet<ObjectId>) -> Planner<'a> {
Planner {
level,
dir,
stepped,
plan: Plan::default(),
}
}
/// Does object `id` have `property`, under the rules in force before the move?
pub fn has(&self, id: ObjectId, property: &str) -> bool {
self.level.has_property(id, property)
}
/// The objects that will still be on tile `at` when the plan so far is
/// committed: those there now, less those planned to move away or be destroyed.
/// Oldest first.
pub fn staying_at(&self, (x, y): (i32, i32)) -> Vec<ObjectId> {
self.level
.board()
.at(x, y)
.map(|o| o.id)
.filter(|id| !self.plan.moving.contains(id) && !self.plan.destroyed.contains(id))
.collect()
}
/// Plan object `id`'s destruction.
pub fn destroy(&mut self, id: ObjectId) {
self.plan.destroyed.insert(id);
}
/// Plan object `id` stepping one tile in the move's direction, with whatever
/// the responses add. Returns whether it can. If it can't, the plan is left as
/// it was before the call. An object already planned to move, or to be
/// destroyed, is out of the way: the answer is yes, and nothing is added. An
/// object that has already stepped in this movement phase doesn't step again, and
/// doesn't hold the mover back either: the answer is yes, and nothing is added, so
/// objects that began the phase stacked on one tile end it stacked.
pub fn try_move(&mut self, id: ObjectId) -> bool {
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
return true;
}
if self.stepped.contains(&id) {
return true;
}
let level = self.level;
let o = level.board().get(id);
let (dx, dy) = self.dir.delta();
let to = (o.x + dx, o.y + dy);
// The edge of the level blocks every step (the course's choice; see the lesson).
if !level.board().in_bounds(to.0, to.1) {
return false;
}
let before = self.plan.clone();
self.plan.moving.insert(id);
let step = Step {
mover: id,
from: (o.x, o.y),
to,
dir: self.dir,
};
let allowed = level.responses().iter().all(|respond| respond(self, step));
if !allowed {
self.plan = before;
}
allowed
}
pub fn into_plan(self) -> Plan {
self.plan
}
}
@@ -0,0 +1,313 @@
//! The parser: words on the grid become rules.
//!
//! Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`,
//! read left to right in a row and top to bottom in a column. The parser starts at
//! every `IS`, collects the subjects before it and the complements after it, and
//! emits one rule per pair: AND distributes over both sides. A tile holding several
//! words contributes each of them, so stacked text yields every possible rule.
//!
//! `NOT` before a complement negates that one complement. `NOT` before a subject is
//! not in the grammar: the run of subjects stops there and that subject is dropped.
//!
//! Module 11: conditions. `NOUN [AND NOUN]* [CONDITION NOUN [AND NOUN]*] IS ...`: a word
//! whose role is `Condition` may stand between the subjects and `IS`, followed by
//! the nouns it is about. The condition belongs to every subject of the sentence,
//! and AND among its nouns means all of them. A run of nouns before `IS` is the
//! condition's when a condition word stands before it, and then it is not a run of
//! subjects. Stacked text still yields every possible rule: a stack of nouns after
//! the condition word gives one rule per noun, as a stack after `IS` does.
//!
//! The parser only reads; which rules win is the resolution pass (`resolve`), and
//! whether a condition holds is decided for each object when someone asks (`Level`).
use std::collections::BTreeMap;
use std::fmt;
use super::grid::{Board, ObjectId};
use super::word::{lex, Lexicon, Role, Token, AND, IS, NOT, TEXT};
/// A condition on a rule (Module 11): the condition word and the nouns after it, in
/// the order they are written. `X ON Y AND Z IS P` has `word: "ON"`,
/// `nouns: ["Y", "Z"]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Condition {
pub word: String,
pub nouns: Vec<String>,
}
/// One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`,
/// applying to an object only while each of its `conditions` holds for that object
/// (none for an unconditional rule). `text` is the text objects that spell it
/// (subject, condition word and nouns if any, `IS`, `NOT` if any, complement), or
/// empty for a base rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub subject: String,
pub complement: String,
pub complement_role: Role,
pub negated: bool,
pub conditions: Vec<Condition>,
pub text: Vec<ObjectId>,
}
impl Rule {
/// `X IS Y` where `Y` is a noun: every X is to become a Y.
pub fn is_conversion(&self) -> bool {
self.complement_role == Role::Noun
}
/// `X IS X` (or `X IS NOT X`).
pub fn is_identity(&self) -> bool {
self.subject == self.complement
}
/// Same subject, complement and conditions, opposite sign: `X IS NOT Y` negates
/// `X IS Y`, and `X ON Z IS NOT Y` negates `X ON Z IS Y` (Module 11), but not `X IS Y`.
pub fn negates(&self, other: &Rule) -> bool {
self.negated
&& !other.negated
&& self.subject == other.subject
&& self.complement == other.complement
&& self.same_conditions(other)
}
/// The same conditions: the same words, each with the same nouns in any order.
pub fn same_conditions(&self, other: &Rule) -> bool {
let key = |r: &Rule| -> Vec<(String, Vec<String>)> {
r.conditions
.iter()
.map(|c| {
let mut nouns = c.nouns.clone();
nouns.sort();
(c.word.clone(), nouns)
})
.collect()
};
key(self) == key(other)
}
}
/// The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`, `"X ON Y AND Z IS P"`.
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.subject)?;
for c in &self.conditions {
write!(f, " {} {}", c.word, c.nouns.join(&format!(" {AND} ")))?;
}
let not = if self.negated { " NOT" } else { "" };
write!(f, " {IS}{not} {}", self.complement)
}
}
/// Rules in force with no text: the game's base rules (`(subject, property)`).
pub const BASE_RULES: &[(&str, &str)] = &[(TEXT, "PUSH")];
type Tiles = BTreeMap<(i32, i32), Vec<Token>>;
/// A word in a run, with the `NOT` in front of it if there is one.
struct Word<'a> {
not: Option<&'a Token>,
token: &'a Token,
}
/// Every rule the board spells, base rules first, negated rules included. A rule
/// spelled by two different sets of text objects appears twice.
pub fn parse(board: &Board, lexicon: &Lexicon) -> Vec<Rule> {
let tiles = lex(board, lexicon);
let mut rules: Vec<Rule> = BASE_RULES
.iter()
.map(|&(subject, property)| Rule {
subject: subject.to_string(),
complement: property.to_string(),
complement_role: Role::Property,
negated: false,
conditions: vec![],
text: vec![],
})
.collect();
for (&(x, y), tokens) in &tiles {
for is in tokens.iter().filter(|t| t.word == IS) {
for (dx, dy) in [(1, 0), (0, 1)] {
let complements = complements(&tiles, (x + dx, y + dy), (dx, dy));
for reading in before_is(&tiles, (x - dx, y - dy), (-dx, -dy)) {
for s in &reading.subjects {
for c in &complements {
let mut text = vec![s.id];
text.extend(&reading.text);
text.push(is.id);
text.extend(c.not.map(|n| n.id));
text.push(c.token.id);
rules.push(Rule {
subject: s.word.clone(),
complement: c.token.word.clone(),
complement_role: c.token.role,
negated: c.not.is_some(),
conditions: reading.conditions.clone(),
text,
});
}
}
}
}
}
}
rules
}
fn words_at(tiles: &Tiles, p: (i32, i32)) -> &[Token] {
tiles.get(&p).map(Vec::as_slice).unwrap_or(&[])
}
fn holds(tiles: &Tiles, p: (i32, i32), word: &str) -> bool {
words_at(tiles, p).iter().any(|t| t.word == word)
}
fn ahead(p: (i32, i32), step: (i32, i32)) -> (i32, i32) {
(p.0 + step.0, p.1 + step.1)
}
/// What stands before an `IS` (Module 11): the subjects, with the condition on them
/// if there is one.
struct Reading<'a> {
subjects: Vec<&'a Token>,
conditions: Vec<Condition>,
/// The condition word and its nouns, in reading order (empty without a condition).
text: Vec<ObjectId>,
}
/// A run of words walking backwards, and the tile the walk stopped on, before the
/// run's first word.
struct Run<'a> {
tokens: Vec<&'a Token>,
stop: (i32, i32),
}
fn has_condition(tiles: &Tiles, p: (i32, i32)) -> bool {
words_at(tiles, p).iter().any(|t| t.role == Role::Condition)
}
/// Every way to read the words before an `IS`, walking backwards from `at`.
///
/// - The run of subjects, without a condition, unless a condition word stands
/// before the run: then those nouns are the condition's, not subjects.
/// - For every run of nouns that has a condition word before it, the run of
/// subjects before that word, with the condition. A stack among the condition's
/// nouns gives one reading per noun (`noun_runs`).
fn before_is(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<Reading<'_>> {
let mut readings = vec![];
let run = subjects(tiles, at, step);
if !has_condition(tiles, run.stop) {
readings.push(Reading {
subjects: run.tokens,
conditions: vec![],
text: vec![],
});
}
for nouns in noun_runs(tiles, at, step) {
let in_order: Vec<&Token> = nouns.tokens.iter().rev().copied().collect();
let words = words_at(tiles, nouns.stop).iter();
for word in words.filter(|t| t.role == Role::Condition) {
let mut text = vec![word.id];
text.extend(in_order.iter().map(|t| t.id));
readings.push(Reading {
subjects: subjects(tiles, ahead(nouns.stop, step), step).tokens,
conditions: vec![Condition {
word: word.word.clone(),
nouns: in_order.iter().map(|t| t.word.clone()).collect(),
}],
text,
});
}
}
readings
}
/// The run of subjects before an `IS`, walking backwards from `at`: the nouns at
/// `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT`
/// before a noun ends the run without it (a negated subject is not in the grammar).
fn subjects(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Run<'_> {
let before = ahead(at, step);
if holds(tiles, before, NOT) {
return Run {
tokens: vec![],
stop: before,
};
}
let mut found: Vec<&Token> = words_at(tiles, at)
.iter()
.filter(|t| t.role == Role::Noun)
.collect();
if !found.is_empty() && holds(tiles, before, AND) {
let rest = subjects(tiles, ahead(before, step), step);
if !rest.tokens.is_empty() {
found.extend(rest.tokens);
return Run {
tokens: found,
stop: rest.stop,
};
}
}
Run {
tokens: found,
stop: before,
}
}
/// Every run of nouns walking backwards from `at`, one noun from each tile, joined
/// by AND; each with the tile before its first noun. Runs that end early, at an
/// AND, are included: a condition word stacked on that AND starts a reading. A
/// `NOT` before a noun ends every run through it.
fn noun_runs(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<Run<'_>> {
let before = ahead(at, step);
if holds(tiles, before, NOT) {
return vec![];
}
let mut runs = vec![];
for noun in words_at(tiles, at).iter().filter(|t| t.role == Role::Noun) {
runs.push(Run {
tokens: vec![noun],
stop: before,
});
if !holds(tiles, before, AND) {
continue;
}
for rest in noun_runs(tiles, ahead(before, step), step) {
let mut tokens = vec![noun];
tokens.extend(rest.tokens);
runs.push(Run {
tokens,
stop: rest.stop,
});
}
}
runs
}
/// The run of complements after an `IS`, walking forwards from `at`: the nouns
/// and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it,
/// negated; then, if the next tile holds an AND, the run beyond it.
fn complements(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<Word<'_>> {
let fits = |t: &&Token| matches!(t.role, Role::Noun | Role::Property);
let mut found = vec![];
// Plain words here, and negated words one tile on: a stack may hold both.
let mut branches: Vec<(Option<&Token>, (i32, i32))> = vec![(None, at)];
for not in words_at(tiles, at).iter().filter(|t| t.word == NOT) {
branches.push((Some(not), ahead(at, step)));
}
for (not, p) in branches {
let words: Vec<Word> = words_at(tiles, p)
.iter()
.filter(fits)
.map(|token| Word { not, token })
.collect();
if words.is_empty() {
continue;
}
found.extend(words);
let and_at = ahead(p, step);
if holds(tiles, and_at, AND) {
found.extend(complements(tiles, ahead(and_at, step), step));
}
}
found
}
@@ -0,0 +1,39 @@
//! The resolution pass: from every rule the text spells to the rules in force.
//!
//! The parser reports what is written; this pass decides what wins, over the whole
//! rule list at once, so it doesn't matter where on the board each rule is written:
//!
//! 1. **Negation.** `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in
//! force: it keeps overriding, and `X IS NOT X` is also the record that every X is
//! to vanish (the turn removes them, Module 9).
//! 2. **Identity.** A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun):
//! X can't become anything else. It doesn't stop other nouns becoming X. Because
//! negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again.
//!
//! Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`.
//!
//! Module 11: both steps pair rules only when their conditions are the same.
//! `X ON Z IS NOT P` cancels `X ON Z IS P` here, but not `X IS P`:
//! which of those wins depends on where each X stands, so it is settled per
//! object when someone asks (`Level::has_property`, and conversion), among the
//! rules whose conditions hold for that object.
use super::parse::Rule;
/// The rules in force, in the order they were parsed.
pub fn resolve(rules: &[Rule]) -> Vec<Rule> {
let survives_negation: Vec<&Rule> = rules
.iter()
.filter(|r| !rules.iter().any(|n| n.negates(r)))
.collect();
let locked = |r: &Rule| {
survives_negation.iter().any(|l| {
!l.negated && l.is_identity() && l.subject == r.subject && l.same_conditions(r)
})
};
survives_negation
.iter()
.filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(r))
.map(|&r| r.clone())
.collect()
}
@@ -0,0 +1,44 @@
//! The turn (Module 9): one move of the player, as a fixed list of phases.
//!
//! The course's turn is a labelled simplification of the game's (see the lesson):
//! movement, parse, conversion, parse, hazards, win, and a last parse so that
//! the rules match the board when the turn ends. Each phase reads the rules from
//! the parse before it, so a rule that movement breaks is gone before hazards run.
//!
//! What a hazard property does (`SINK`, `HOT`/`MELT`, `DEFEAT`, ...) is a `Hazard`
//! that content provides, as with movement's responses. The engine runs them in
//! content's order and owns only the phases, `YOU`, `WIN` and float levels.
use super::grid::ObjectId;
use super::level::Level;
/// One phase of the turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Every `YOU` object moves (Module 8), without the reparse.
Movement,
/// The rules are recompiled from the board.
Parse,
/// `X IS Y` conversions, and `X IS NOT X` deletions.
Conversion,
/// Content's hazards, in content's order.
Hazards,
/// A `YOU` object on a `WIN` one (or `WIN` itself) wins the level.
Win,
}
/// The turn, in order.
pub const TURN: [Phase; 7] = [
Phase::Movement,
Phase::Parse,
Phase::Conversion,
Phase::Parse,
Phase::Hazards,
Phase::Win,
Phase::Parse,
];
/// A hazard: content's rule for what destroys what at the end of a turn. It reads
/// the level and returns the objects to destroy; the engine destroys them all at
/// once, before the next hazard runs. An id may appear more than once.
pub type Hazard = fn(&Level) -> Vec<ObjectId>;
@@ -0,0 +1,135 @@
//! The lexer: text objects become words with a grammatical role.
//!
//! The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`.
//! Every other word comes from content as data: the engine never names a noun.
//! A noun also says which objects it names (`Selects`), so a meta-noun that names
//! a whole group of objects can be data too.
//!
//! Module 11: a fourth role, `Condition`. The engine knows only where a condition
//! word may stand in a sentence (between the subjects and `IS`); what it means is a
//! check content provides (`ConditionCheck`).
use std::collections::{BTreeMap, HashMap};
use serde::Deserialize;
use super::grid::{Board, Kind, ObjectId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Noun,
Property,
Operator,
Condition,
}
/// Which objects a noun names when it is the subject of a rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Selects {
/// The things of its own name: `<NOUN>` names every object of kind `"<noun>"`.
#[default]
Named,
/// Every text object (the meta-noun `TEXT`).
Text,
/// Every object that is not text, whatever its noun.
Things,
}
/// One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`,
/// optionally with `"selects"` (default `"named"`).
#[derive(Debug, Clone, Deserialize)]
pub struct WordDef {
pub name: String,
pub role: Role,
#[serde(default)]
pub selects: Selects,
}
pub const IS: &str = "IS";
pub const AND: &str = "AND";
pub const NOT: &str = "NOT";
/// The meta-noun for every text object.
pub const TEXT: &str = "TEXT";
/// Every word the parser can read, with its role.
#[derive(Debug, Clone, Default)]
pub struct Lexicon {
roles: HashMap<String, Role>,
selects: HashMap<String, Selects>,
}
impl Lexicon {
/// The engine's own words plus `words`. Content can't redefine an engine word.
pub fn new(words: &[WordDef]) -> Lexicon {
let mut roles: HashMap<String, Role> = words
.iter()
.map(|w| (w.name.to_uppercase(), w.role))
.collect();
let mut selects: HashMap<String, Selects> = words
.iter()
.map(|w| (w.name.to_uppercase(), w.selects))
.collect();
for op in [IS, AND, NOT] {
roles.insert(op.to_string(), Role::Operator);
}
roles.insert(TEXT.to_string(), Role::Noun);
selects.insert(TEXT.to_string(), Selects::Text);
Lexicon { roles, selects }
}
/// `None` for a word the lexicon doesn't know: such text spells nothing.
pub fn role(&self, word: &str) -> Option<Role> {
self.roles.get(word).copied()
}
/// Does the noun `word`, as the subject of a rule, name an object of this kind?
pub fn names(&self, word: &str, kind: &Kind) -> bool {
let selects = self.selects.get(word).copied().unwrap_or_default();
match (selects, kind) {
(Selects::Named, Kind::Thing { noun }) => noun == word,
(Selects::Text, Kind::Text { .. }) => true,
(Selects::Things, Kind::Thing { .. }) => true,
_ => false,
}
}
/// What a conversion into the noun `word` makes (Module 9): a thing of that
/// name, if `word` names the things of its own name. `None` for a noun that
/// names a group (`TEXT`, or `ALL` if content adds it): the course doesn't
/// convert into those.
pub fn thing_named(&self, word: &str) -> Option<Kind> {
let selects = self.selects.get(word).copied().unwrap_or_default();
(selects == Selects::Named).then(|| Kind::Thing {
noun: word.to_string(),
})
}
}
/// A piece of text the parser can read: which object, which word, which role.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub id: ObjectId,
pub word: String,
pub role: Role,
}
/// The words on the board, by tile; a tile's words in object order (oldest first).
/// Text whose word the lexicon doesn't know is left out.
pub fn lex(board: &Board, lexicon: &Lexicon) -> BTreeMap<(i32, i32), Vec<Token>> {
let mut tiles: BTreeMap<(i32, i32), Vec<Token>> = BTreeMap::new();
for object in board.objects() {
let Kind::Text { word } = &object.kind else {
continue;
};
if let Some(role) = lexicon.role(word) {
tiles.entry((object.x, object.y)).or_default().push(Token {
id: object.id,
word: word.clone(),
role,
});
}
}
tiles
}
@@ -0,0 +1,480 @@
//! The action queue: how one player action (a Sequence) resolves.
//!
//! Performing an action changes the world and produces events. Every trigger that
//! responds to those events goes into one Queue, oldest listener first, and the Queue
//! is locked. Each queued trigger then resolves completely, including everything
//! its own actions cause, before the next one starts: depth-first.
//!
//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
//! Death Creation Step removes the dead, and a Death Phase resolves their
//! Deathrattles. That repeats until a step finds no new deaths.
//!
//! Module 4: a queued trigger may activate more than once. How many times is
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
//! Module 4, second interceptor: replacement. Before a Damage changes the world,
//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
//! who takes it, then Armor takes what it can of damage to a hero.
//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
//!
//! Module 5, robustness: once a test can write any card as data, one card can make a
//! Sequence endless. A trigger is never queued while that same trigger of that same
//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more
//! than `DEATH_PHASE_LIMIT` Death Phases is an error.
//!
//! Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`,
//! read from the world, as Part II's turn is a list of phases. SpellGrid's tests run
//! the same Sequence with the list reversed, to show what the order decides.
//!
//! Module 12: combat and the end of a turn. An attack is one Damage action whose two
//! targets take different amounts (`Hit`), and the triggers answering the defender's
//! Damage Event queue before the attacker's, whatever their order of play. That
//! exception is data on the queue entry, a priority: `react` orders its Queue by
//! priority first and keeps the order it was filled in (order of play) within one
//! priority. Ending a turn raises an event that the turn's player's end of turn
//! triggers answer.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::combat::attack_error;
use super::death::death_creation_step;
use super::effect::actions;
use super::event::Event;
use super::world::{EntityId, PlayerId, World, Zone};
/// How early the triggers answering an event queue (Module 12). A Queue resolves its
/// entries highest priority first and, within one priority, in the order it was filled:
/// for each event in order of play, each listener in order of play.
pub type Priority = i32;
/// The priority of every entry, except where a rule says otherwise.
pub const NORMAL: Priority = 0;
/// Combat: the triggers answering the defender's Damage Event go first.
pub const DEFENDER: Priority = 1;
/// One target of a Damage action, the amount it takes, and the priority of the triggers
/// its Damage Event queues.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hit {
pub target: EntityId,
pub amount: i32,
pub priority: Priority,
}
/// The same amount to each target, at normal priority: what area damage and a spell deal.
pub fn hits(targets: &[EntityId], amount: i32) -> Vec<Hit> {
targets
.iter()
.map(|&target| Hit {
target,
amount,
priority: NORMAL,
})
.collect()
}
/// Something the engine can do. Card effects return these instead of changing the world.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Deal each hit's amount to its target at once: all damage first, then the reactions.
Damage {
hits: Vec<Hit>,
},
Draw {
player: PlayerId,
},
GainArmor {
player: PlayerId,
amount: i32,
},
GainAttack {
id: EntityId,
amount: i32,
},
Cast {
card: String,
player: PlayerId,
target: Option<EntityId>,
},
/// Mark each target pending destroy. They leave play in the next Death Creation Step.
Destroy {
targets: Vec<EntityId>,
},
/// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`.
Summon {
card: String,
player: PlayerId,
at: Option<usize>,
},
/// Play a minion from its owner's hand: it enters the right end of the board, then its
/// Battlecry resolves.
Play {
id: EntityId,
},
/// Put an Assembled minion into `player`'s hand (see `assemble.rs`).
Assemble {
player: PlayerId,
filter: PoolFilter,
},
/// Give one entity +attack/+health.
Buff {
id: EntityId,
attack: i32,
health: i32,
},
/// Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it.
SpawnText {
source: EntityId,
word: String,
},
/// Combat (Module 12): `attacker` and `defender` deal their Attack to each other at once.
Attack {
attacker: EntityId,
defender: EntityId,
},
/// The end of `player`'s turn (Module 12): their minions' end of turn triggers answer it.
EndTurn {
player: PlayerId,
},
}
/// A step that runs when the outermost Phase ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndOfPhaseStep {
/// The Death Creation Step (Module 3).
DeathCreation,
/// An Aura Update (Other) (Module 4).
AuraUpdate,
}
/// The end of the outermost Phase, in Hearthstone's order: the dead leave play first,
/// then the auras are read again, so a minion that just died multiplies nothing in
/// the Death Phase that follows.
pub const END_OF_PHASE: [EndOfPhaseStep; 2] =
[EndOfPhaseStep::DeathCreation, EndOfPhaseStep::AuraUpdate];
/// A step that may rewrite one Damage to one target before the world changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DamageStep {
/// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
/// aura's minion instead (the oldest one's, if several).
Redirect,
/// Damage to a hero lowers its owner's Armor first, then Health.
Armor,
}
/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
/// never offered to a redirect again. A redirect comes first: Armor protects only
/// the hero that actually takes the damage.
pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
/// The player the effect works for.
pub owner: PlayerId,
/// The minion whose trigger is resolving; `None` for a spell.
pub source: Option<EntityId>,
/// The chosen target, if any.
pub target: Option<EntityId>,
/// The source's board position. For a Deathrattle, the source has left play, and
/// this is where it stood: immediately left of the minion its Death Event remembers.
pub position: Option<usize>,
}
impl Ctx<'_> {
/// The minion whose trigger is resolving.
pub fn minion(&self) -> EntityId {
self.source.expect("only a triggered ability has a source")
}
pub fn enemy_hero(&self) -> EntityId {
self.world.hero(1 - self.owner)
}
}
/// The most Death Phases one Sequence may have. One more is an error, not a cut-off:
/// a Sequence that stopped early would resolve differently without saying so.
pub const DEATH_PHASE_LIMIT: u32 = 64;
/// Run one Sequence: a player action and everything it causes.
pub fn run_sequence(world: &mut World, action: Action) {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let mut deaths = end_of_outermost_phase(world);
let mut round = 0;
while !deaths.is_empty() {
round += 1;
// Between Death Phases no trigger is resolving, so the panic leaves no block open.
assert!(
round <= DEATH_PHASE_LIMIT,
"Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence"
);
world.record(Event::DeathPhase { round });
react(world, &deaths, &[]);
deaths = end_of_outermost_phase(world);
}
}
/// The steps that run only when the outermost Phase ends, in the world's order
/// (`END_OF_PHASE` unless changed); returns the Death Events. Hearthstone runs an
/// Aura Update (Health/Attack) first. The course has no Health/Attack auras, so that
/// step has nothing to do and is left out.
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
let mut deaths = Vec::new();
for step in world.end_of_phase().to_vec() {
match step {
EndOfPhaseStep::DeathCreation => deaths = death_creation_step(world),
EndOfPhaseStep::AuraUpdate => aura_update_other(world),
}
}
deaths
}
/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an
/// Aura Update.
pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option<usize>) -> EntityId {
let id = world.create_at(card, player, Zone::Board, at);
aura_update_other(world);
id
}
fn perform(world: &mut World, action: Action) {
match action {
Action::Damage { mut hits } => {
// Area damage: every Damage Event is created, in order of play, before any reaction.
hits.sort_by_key(|hit| world.order_of_play(hit.target));
let mut events = Vec::new();
for hit in &hits {
events.push(apply_damage(world, hit.target, hit.amount));
}
let priorities: Vec<Priority> = hits.iter().map(|hit| hit.priority).collect();
react(world, &events, &priorities);
}
Action::Attack { attacker, defender } => {
if let Some(error) = attack_error(world, attacker, defender) {
panic!("{error}");
}
// Both amounts are read before either is dealt: the damage is simultaneous. A
// hero has no Attack, so it deals nothing back; nor does a 0 Attack minion.
let mut attack = vec![Hit {
target: defender,
amount: world.attack(attacker),
priority: DEFENDER,
}];
let counter = world.attack(defender);
if counter > 0 {
attack.push(Hit {
target: attacker,
amount: counter,
priority: NORMAL,
});
}
perform(world, Action::Damage { hits: attack });
}
Action::EndTurn { player } => {
let ended = Event::TurnEnded { player };
world.record(ended.clone());
react(world, &[ended], &[]);
}
Action::Draw { player } => world.draw(player),
Action::GainArmor { player, amount } => world.gain_armor(player, amount),
Action::GainAttack { id, amount } => world.gain_attack(id, amount),
Action::Destroy { targets } => {
for target in targets {
world.mark_destroyed(target);
}
}
Action::Summon { card, player, at } => {
summon(world, &card, player, at);
}
Action::Play { id } => {
assert!(
world.zone_of(id) == Zone::Hand,
"play: entity {id} is not in a hand"
);
// Playing is not summoning: no Aura Update here. The played minion's own
// auras join at the end of the outermost Phase.
world.move_to(id, Zone::Board);
let played = Event::Played { id };
world.record(played.clone());
react(world, &[played], &[]);
}
Action::Cast {
card,
player,
target,
} => {
let spell = world.card(&card);
assert!(spell.is_spell(), "{card:?} is not a spell");
let effects = spell.on_cast.clone();
world.record(Event::SpellCast { card });
run_effects(world, &effects, player, None, target, None);
}
Action::Assemble { player, filter } => {
assemble_from_pool(world, player, &filter);
}
Action::Buff { id, attack, health } => world.buff(id, attack, health),
Action::SpawnText { source, word } => world.record(Event::TextSpawned { source, word }),
}
}
/// Interpret and perform an ability's effects in printed order. Each effect sees the
/// world as the effects before it left it. Depth-first: each action, and every
/// reaction it causes, finishes before the next.
fn run_effects(
world: &mut World,
effects: &[Effect],
owner: PlayerId,
source: Option<EntityId>,
target: Option<EntityId>,
position: Option<usize>,
) {
for effect in effects {
let ctx = Ctx {
world,
owner,
source,
target,
position,
};
for action in actions(effect, &ctx) {
perform(world, action);
}
}
}
/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
/// Returns its Damage Event, which names whoever took it.
fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
let mut taker = target;
let mut absorbed = 0;
for step in DAMAGE_STEPS {
match step {
DamageStep::Redirect => {
if world.is_hero(taker) {
taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
}
}
DamageStep::Armor => {
if world.is_hero(taker) {
let player = world.owner_of(taker);
absorbed = world.armor(player).min(amount);
if absorbed > 0 {
world.lose_armor(player, absorbed);
}
}
}
}
}
world.damage(taker, amount, absorbed);
Event::Damaged {
target: taker,
amount,
}
}
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
/// `priorities[i]` is the priority of the entries answering `events[i]` (`NORMAL` if absent).
fn react(world: &mut World, events: &[Event], priorities: &[Priority]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
// The nesting guard: a trigger that is resolving right now is not queued again, so
// a trigger is never nested inside itself.
let mut queue = Vec::new();
for (e, event) in events.iter().enumerate() {
let priority = priorities.get(e).copied().unwrap_or(NORMAL);
for listener in listeners(world, event) {
for (index, trigger) in world
.card(world.card_name(listener))
.triggers
.iter()
.enumerate()
{
if responds(world, trigger.on, listener, event)
&& !world.is_resolving(listener, index)
{
queue.push((listener, index, trigger.clone(), event, priority));
}
}
}
}
// Priority first; the sort is stable, so entries of one priority keep the order above.
queue.sort_by_key(|entry| std::cmp::Reverse(entry.4));
// The Queue is now locked: nothing that happens below adds to it.
for (source, index, trigger, event, _) in queue {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
let times = activations(world, world.owner_of(source), trigger.on);
for _ in 0..times {
world.record(Event::Triggered {
source,
on: trigger.on,
});
let position = match *event {
Event::Destroyed { id, before } if id == source => {
Some(position_left_of(world, world.owner_of(source), before))
}
_ => world.board_index(source),
};
// The trigger's consequences are one level deeper in the log tree.
world.open_block(source, index);
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
world.close_block();
}
}
}
/// The board index, right now, immediately left of `before` on `owner`'s board; the
/// right end if `before` is `None` or no longer there. A Death Event remembers the
/// minion, not an index, so this still holds after other minions have left or arrived.
fn position_left_of(world: &World, owner: PlayerId, before: Option<EntityId>) -> usize {
let board = world.zone(owner, Zone::Board);
before
.and_then(|before| board.iter().position(|&id| id == before))
.unwrap_or(board.len())
}
/// Who may respond to `event`, in order of play: every minion in play and, for a
/// Death Event, the minion that died. It has left play, but its Deathrattle still answers.
fn listeners(world: &World, event: &Event) -> Vec<EntityId> {
let mut ids = world.minions_in_play();
if let Event::Destroyed { id, .. } = *event {
ids.push(id);
ids.sort_by_key(|&id| world.order_of_play(id));
}
ids
}
/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`?
fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool {
if let Event::Destroyed { id, .. } = *event {
return on == TriggerOn::Deathrattle && id == listener;
}
if let Event::Played { id } = *event {
return on == TriggerOn::Battlecry && id == listener;
}
if let Event::TurnEnded { player } = *event {
return on == TriggerOn::EndOfTurn && world.owner_of(listener) == player;
}
let Event::Damaged { target, .. } = *event else {
return false;
};
let is_minion = !world.is_hero(target);
match on {
TriggerOn::SelfDamaged => target == listener,
TriggerOn::FriendlyMinionDamaged => {
is_minion && world.owner_of(target) == world.owner_of(listener)
}
TriggerOn::AnyMinionDamaged => is_minion,
TriggerOn::HeroDamaged => !is_minion,
TriggerOn::Deathrattle | TriggerOn::Battlecry | TriggerOn::EndOfTurn => false,
}
}
@@ -0,0 +1,418 @@
//! 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::{
ConditionChecks, 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`.
///
/// Module 11: a rule with a condition gives no aura. A condition holds or not for one
/// object on the grid, and a trigger word names no object (the course's choice).
pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec<Aura> {
let mut auras = Vec::new();
for rule in rules {
if !rule.conditions.is_empty() {
continue;
}
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>,
conditions: ConditionChecks,
seam_words: SeamWords,
) -> SpellGrid {
let mut level = Level::new(def, words, responses, hazards, conditions);
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);
}
}
@@ -0,0 +1,456 @@
//! Entities, zones, order of play, stats and the event log: the game's state.
//! The rules for resolving actions live in `queue.rs`.
use super::card::{Aura, Card, CardDef, CardLibrary};
use super::event::Event;
use super::queue::{EndOfPhaseStep, END_OF_PHASE};
/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone.
pub type EntityId = u32;
pub type PlayerId = u8;
/// Starting Health of each hero.
pub const HERO_HEALTH: i32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Zone {
Deck,
Hand,
Board,
Graveyard,
}
#[derive(Debug)]
struct Entity {
card: String,
owner: PlayerId,
zone: Zone,
/// Stamped each time the entity enters the board; `None` until it first does.
order_of_play: Option<u32>,
attack: i32,
/// Current Health. At 0 or below the entity is mortally wounded: still in play until the
/// Death Creation Step.
health: i32,
/// Hit by a destroy effect: still in play until the Death Creation Step.
pending_destroy: bool,
}
/// An aura in force: which minion has it, and that minion's owner. An aura from outside
/// the cards (Module 10) has no minion (`source` is `None`), and is in force once for
/// each player.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
pub source: Option<EntityId>,
pub aura: Aura,
}
/// One player's zones. Each is an ordered list of ids (the board is left to right).
#[derive(Debug, Default)]
struct Side {
deck: Vec<EntityId>,
hand: Vec<EntityId>,
board: Vec<EntityId>,
graveyard: Vec<EntityId>,
hero: EntityId,
armor: i32,
}
impl Side {
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
match zone {
Zone::Deck => &self.deck,
Zone::Hand => &self.hand,
Zone::Board => &self.board,
Zone::Graveyard => &self.graveyard,
}
}
fn zone_mut(&mut self, zone: Zone) -> &mut Vec<EntityId> {
match zone {
Zone::Deck => &mut self.deck,
Zone::Hand => &mut self.hand,
Zone::Board => &mut self.board,
Zone::Graveyard => &mut self.graveyard,
}
}
}
/// Makes a player's choices: called with what is being chosen and the options, it
/// returns one of the options. Hearthstone would ask the player or pick at random.
pub type Chooser = Box<dyn FnMut(&str, &[String]) -> String>;
pub struct World {
library: CardLibrary,
chooser: Chooser,
/// Indexed by `EntityId`.
entities: Vec<Entity>,
sides: [Side; 2],
next_order_of_play: u32,
log: Vec<Event>,
/// The nesting depth at which each event in `log` was recorded, index for index.
depths: Vec<u32>,
/// The triggers resolving right now, one inside another, outermost first: each is a
/// minion and the index of the trigger in its card's `triggers`. How many there are
/// is the nesting depth of the log tree.
resolving: Vec<(EntityId, usize)>,
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
/// Module 10: auras that no minion carries, for every player's minions. SpellGrid
/// compiles them from the rules on its grid. The next Aura Update brings them in.
rule_auras: Vec<Aura>,
/// Module 10: the steps at the end of the outermost Phase, in order.
end_of_phase: Vec<EndOfPhaseStep>,
}
impl World {
/// A new game: both heroes are in play from the start, so they are the oldest entities.
pub fn new(library: CardLibrary) -> World {
let mut world = World {
library,
// Until a chooser is set, take the first option.
chooser: Box::new(|_, options| options[0].clone()),
entities: Vec::new(),
sides: Default::default(),
next_order_of_play: 0,
log: Vec::new(),
depths: Vec::new(),
resolving: Vec::new(),
auras: Vec::new(),
rule_auras: Vec::new(),
end_of_phase: END_OF_PHASE.to_vec(),
};
for owner in 0..2 {
let id = world.entities.len() as EntityId;
world.next_order_of_play += 1;
world.entities.push(Entity {
card: "Hero".to_string(),
owner,
zone: Zone::Board,
order_of_play: Some(world.next_order_of_play),
attack: 0,
health: HERO_HEALTH,
pending_destroy: false,
});
// In play, but not on the board list: the board holds minions only.
world.sides[owner as usize].hero = id;
}
world
}
/// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board).
pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId {
self.create_at(card, owner, zone, None)
}
/// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the
/// zone's length), or at the end if `at` is `None`.
pub fn create_at(
&mut self,
card: &str,
owner: PlayerId,
zone: Zone,
at: Option<usize>,
) -> EntityId {
assert!(owner < 2, "no player {owner}");
let def = &self.library.get(card).def;
let entity = Entity {
card: def.name.clone(),
owner,
zone,
order_of_play: None,
attack: def.attack,
health: def.health,
pending_destroy: false,
};
let id = self.entities.len() as EntityId;
self.entities.push(entity);
self.enter(id, zone, at);
id
}
/// Move an entity to the end of `zone` on its owner's side, keeping its id.
pub fn move_to(&mut self, id: EntityId, zone: Zone) {
let (owner, from) = (self.entity(id).owner, self.entity(id).zone);
self.sides[owner as usize]
.zone_mut(from)
.retain(|&e| e != id);
self.enter(id, zone, None);
}
/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
fn enter(&mut self, id: EntityId, zone: Zone, at: Option<usize>) {
let owner = self.entity(id).owner;
let list = self.sides[owner as usize].zone_mut(zone);
list.insert(at.map_or(list.len(), |i| i.min(list.len())), id);
let entity = &mut self.entities[id as usize];
entity.zone = zone;
if zone == Zone::Board {
self.next_order_of_play += 1;
entity.order_of_play = Some(self.next_order_of_play);
// The summoner is the minion whose trigger is resolving (the innermost one), if any.
let by = self.resolving.last().map(|&(source, _)| source);
self.record(Event::Summoned { id, by });
}
}
fn entity(&self, id: EntityId) -> &Entity {
self.entities
.get(id as usize)
.unwrap_or_else(|| panic!("unknown entity id {id}"))
}
fn entity_mut(&mut self, id: EntityId) -> &mut Entity {
self.entities
.get_mut(id as usize)
.unwrap_or_else(|| panic!("unknown entity id {id}"))
}
// ---- Queries -------------------------------------------------------
pub fn card(&self, name: &str) -> &Card {
self.library.get(name)
}
pub fn has_card(&self, name: &str) -> bool {
self.library.contains(name)
}
/// The cards an Assemble may offer, sorted by name.
pub fn pool(&self) -> Vec<&Card> {
self.library.pool()
}
pub fn zone_of(&self, id: EntityId) -> Zone {
self.entity(id).zone
}
pub fn card_name(&self, id: EntityId) -> &str {
&self.entity(id).card
}
pub fn owner_of(&self, id: EntityId) -> PlayerId {
self.entity(id).owner
}
/// When the entity last entered the board (larger is later); `None` if it never has.
pub fn order_of_play(&self, id: EntityId) -> Option<u32> {
self.entity(id).order_of_play
}
/// The ids in one player's zone, in zone order (left to right for the board).
pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] {
assert!(owner < 2, "no player {owner}");
self.sides[owner as usize].zone(zone)
}
pub fn hero(&self, owner: PlayerId) -> EntityId {
assert!(owner < 2, "no player {owner}");
self.sides[owner as usize].hero
}
pub fn is_hero(&self, id: EntityId) -> bool {
self.sides.iter().any(|s| s.hero == id)
}
/// Every minion on either board, oldest first (order of play).
pub fn minions_in_play(&self) -> Vec<EntityId> {
let mut ids: Vec<EntityId> = self.sides.iter().flat_map(|s| s.board.clone()).collect();
ids.sort_by_key(|&id| self.order_of_play(id));
ids
}
pub fn attack(&self, id: EntityId) -> i32 {
self.entity(id).attack
}
pub fn health(&self, id: EntityId) -> i32 {
self.entity(id).health
}
/// The cost printed on the entity's card.
pub fn cost(&self, id: EntityId) -> i32 {
self.card(self.card_name(id)).def.cost
}
/// The minions in a player's hand, in hand order.
pub fn minions_in_hand(&self, owner: PlayerId) -> Vec<EntityId> {
self.zone(owner, Zone::Hand)
.iter()
.copied()
.filter(|&id| !self.card(self.card_name(id)).is_spell())
.collect()
}
/// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next
/// Death Creation Step.
pub fn is_doomed(&self, id: EntityId) -> bool {
let entity = self.entity(id);
entity.health <= 0 || entity.pending_destroy
}
/// Index of a minion on its owner's board, left to right; `None` if it is not on the board.
pub fn board_index(&self, id: EntityId) -> Option<usize> {
self.zone(self.owner_of(id), Zone::Board)
.iter()
.position(|&e| e == id)
}
pub fn armor(&self, owner: PlayerId) -> i32 {
self.sides[owner as usize].armor
}
pub fn active_auras(&self) -> &[ActiveAura] {
&self.auras
}
/// Auras from outside the cards (Module 10), as last set.
pub fn rule_auras(&self) -> &[Aura] {
&self.rule_auras
}
/// The steps at the end of the outermost Phase, in order.
pub fn end_of_phase(&self) -> &[EndOfPhaseStep] {
&self.end_of_phase
}
/// How many entities exist: ids run from 0 to this, exclusive.
pub fn entity_count(&self) -> u32 {
self.entities.len() as u32
}
pub fn log(&self) -> &[Event] {
&self.log
}
/// The nesting depth of each event in `log()`, index for index: 0 for the outermost
/// action's own events.
pub fn log_depths(&self) -> &[u32] {
&self.depths
}
// ---- Changes: each one records its event ----------------------------
pub fn record(&mut self, event: Event) {
self.log.push(event);
self.depths.push(self.resolving.len() as u32);
}
/// Trigger number `trigger` of `source` starts resolving: the events it causes are
/// recorded one level deeper.
pub fn open_block(&mut self, source: EntityId, trigger: usize) {
self.resolving.push((source, trigger));
}
/// The trigger that last opened a block has finished resolving.
pub fn close_block(&mut self) {
self.resolving.pop();
}
/// Is trigger number `trigger` of `source` resolving right now (at any depth)?
pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool {
self.resolving.contains(&(source, trigger))
}
/// Add a card to the library, or replace the card with the same name.
pub fn register(&mut self, def: CardDef) {
self.library.register(def);
}
/// Add a card made by Assemble (see `assemble.rs`).
pub fn register_assembled(&mut self, def: CardDef) {
self.library.register_assembled(def);
}
pub fn set_chooser(&mut self, chooser: Chooser) {
self.chooser = chooser;
}
/// Ask the chooser to pick one of `options` (not empty). Panics if it answers
/// something that isn't an option.
pub fn choose(&mut self, kind: &str, options: &[String]) -> String {
let answer = (self.chooser)(kind, options);
assert!(
options.contains(&answer),
"chooser answered {answer:?} for {kind:?}; the options were {options:?}"
);
answer
}
/// Replace the auras from outside the cards. They take effect at the next Aura Update.
pub fn set_rule_auras(&mut self, auras: Vec<Aura>) {
self.rule_auras = auras;
}
/// Change the order of the steps at the end of the outermost Phase.
pub fn set_end_of_phase(&mut self, steps: Vec<EndOfPhaseStep>) {
self.end_of_phase = steps;
}
/// Only an Aura Update calls this.
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
self.auras = auras;
}
pub fn clear_log(&mut self) {
self.log.clear();
self.depths.clear();
}
/// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
/// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
/// `player`'s hero's Armor absorbs `amount` damage.
pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
self.sides[player as usize].armor -= amount;
self.record(Event::ArmorLost { player, amount });
}
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
match self.zone(player, Zone::Deck).first() {
Some(&id) => {
self.move_to(id, Zone::Hand);
self.record(Event::Drew { player, id });
}
None => self.record(Event::Fatigue { player }),
}
}
/// Mark for destruction. Nothing leaves play until the Death Creation Step.
pub fn mark_destroyed(&mut self, id: EntityId) {
self.entity_mut(id).pending_destroy = true;
}
/// Move a minion from play to its owner's graveyard, recording its Death Event.
pub fn kill(&mut self, id: EntityId, before: Option<EntityId>) {
self.entity_mut(id).pending_destroy = false;
self.move_to(id, Zone::Graveyard);
self.record(Event::Destroyed { id, before });
}
pub fn gain_armor(&mut self, player: PlayerId, amount: i32) {
self.sides[player as usize].armor += amount;
self.record(Event::ArmorGained { player, amount });
}
pub fn buff(&mut self, id: EntityId, attack: i32, health: i32) {
let entity = self.entity_mut(id);
entity.attack += attack;
entity.health += health;
let (attack, health) = (entity.attack, entity.health);
self.record(Event::StatsChanged { id, attack, health });
}
pub fn gain_attack(&mut self, id: EntityId, amount: i32) {
let entity = self.entity_mut(id);
entity.attack += amount;
let (attack, health) = (entity.attack, entity.health);
self.record(Event::StatsChanged { id, attack, health });
}
}