feat: module 06 parser

First Part II module. The cumulative reference adds a second engine
(src/engine/puzzle: grid, lexer, parser) beside Part I's; nouns and
properties are data in src/content/words.json, and the engine names only
IS/AND/NOT/TEXT and the base rule TEXT IS PUSH. Rules are recompiled from
the board on every change; rules() is a sorted set. Adds the M6 scenarios
and tests (rows, columns, shared IS, AND distribution, stacked text, base
rule), the HOT word as a data-only Expansion Drop, the lesson with the
Part II bridge, and the Rust LevelDef shape in harness.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 03:33:53 +01:00
1 parent e04dc1a11f
commit 68fe9834c3
55 files changed
+5679 -1

No files matched your search

@@ -0,0 +1 @@
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "06" }
@@ -0,0 +1 @@
target/
@@ -0,0 +1,9 @@
[package]
name = "engine"
version = "0.1.0"
edition = "2021"
[dependencies]
# Module 5: cards are JSON data.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+16
View File
@@ -0,0 +1,16 @@
# Module 6 reference engine (Rust)
The course's own engine as of the end of Module 6, the first module of Part II. Part I's card engine is unchanged, and all of its tests still pass. Beside it, in `src/engine/puzzle/`, is a second engine for Baba Is You: a grid of objects and text, a lexer and a parser. Module 5's reference was the starting point, so a diff between the two shows what this module adds.
- `cards/` (at the crate root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it.
- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT` and the property `PUSH` of the base rule `TEXT IS PUSH`.
- `grid.rs`: the level format (`LevelDef`, `course/schema/level.schema.json`), object kinds (`"<noun>"` or `"text:<WORD>"`) and the board: objects on tiles, several per tile allowed.
- `word.rs`: the lexicon (every word the parser can read, with its role: noun, property or operator) and the lexer, which turns the text on the board into words by tile. Text whose word isn't in the lexicon spells nothing.
- `parse.rs`: the parser. From every `IS`, in a row and in a column, it collects the AND-joined subjects before it and the AND-joined complements after it, and emits one rule per pair. Stacked words each count. Base rules come first.
- `level.rs`: a level in play. Every change to the board recompiles the rules from scratch.
- `mod.rs`: the module's exports.
- The rest of `src/engine/`: Part I, unchanged from Module 5 except for the `pub mod puzzle;` line.
- `src/content/words.json`: the nouns and properties the parser knows, as data (`{ "name", "role" }`). `src/content/mod.rs` reads it with `include_str!` as `words()`. A new word is a new entry here.
- `src/harness.rs`: the adapter the course tests call. Module 6 adds `new_baba`, `Baba::place` and `Baba::rules`. `rules()` sorts the rules' normalised strings and keeps each distinct rule once; the engine itself keeps a rule spelled twice twice.
Run it with the module's tests: `node tools/verify-references.mjs 06` from the course repository.
@@ -0,0 +1,48 @@
//! Content: the cards and the Baba Is You words. Only this directory (and the card data in `cards/`) may name
//! specific cards, or specific Baba Is You nouns.
//!
//! From Module 5 every card is a JSON file in `cards/` at the crate root, in the
//! course's card format (`course/schema/card.schema.json`). The functions that
//! were card effects in Modules 2–4 are gone: the engine interprets the data.
//! `start-module` copies the course's card files into `cards/`; add your own there.
use std::path::Path;
use std::sync::OnceLock;
use crate::engine::CardDef;
/// Every card in `cards/`, in file-name order. Read once per test run.
pub fn cards() -> Vec<CardDef> {
static CARDS: OnceLock<Vec<CardDef>> = OnceLock::new();
CARDS
.get_or_init(|| load_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("cards")))
.clone()
}
/// Parse every `.json` file in `dir` as a card definition.
pub fn load_dir(dir: &Path) -> Vec<CardDef> {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|e| panic!("cannot read card directory {}: {e}", dir.display()));
let mut paths: Vec<_> = entries
.map(|entry| entry.expect("card directory entry").path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.collect();
paths.sort();
paths
.iter()
.map(|path| {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{} is not a valid card: {e}", path.display()))
})
.collect()
}
/// Part II: the words of Baba Is You the parser can read, besides the engine's own
/// (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one
/// more entry in `words.json` in this directory.
pub fn words() -> Vec<crate::engine::puzzle::WordDef> {
serde_json::from_str(include_str!("words.json"))
.expect("src/content/words.json is not a word list")
}
@@ -0,0 +1,15 @@
[
{ "name": "BABA", "role": "noun" },
{ "name": "ROCK", "role": "noun" },
{ "name": "WALL", "role": "noun" },
{ "name": "FLAG", "role": "noun" },
{ "name": "LAVA", "role": "noun" },
{ "name": "KEY", "role": "noun" },
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" }
]
@@ -0,0 +1,153 @@
//! 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::{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",
}
}
}
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssembleError {
pub base: String,
pub donor: String,
pub half: Half,
}
impl fmt::Display for AssembleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
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();
if text == base.text {
return Err(AssembleError {
base: base.name.clone(),
donor: donor.name.clone(),
half,
});
}
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 that changes its text, and the half to replace. 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();
let bases: Vec<String> = pool
.iter()
.filter(|(_, allowed)| *allowed)
.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 changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
let donors: Vec<String> = pool
.iter()
.map(|(d, _)| d)
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
.map(|d| d.name.clone())
.collect();
if donors.is_empty() {
return None;
}
let donor_name = world.choose("donor", &donors);
let donor = world.card(&donor_name).def.clone();
let halves: Vec<String> = Half::BOTH
.iter()
.filter(|&&h| changes(&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 change the text are offered");
let name = register_assembled(world, def);
Some(world.create(&name, player, Zone::Hand))
}
@@ -0,0 +1,53 @@
//! Auras, and the one routine that applies trigger multipliers.
//!
//! 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.
use super::card::{Aura, Stacking, TriggerOn};
use super::world::{ActiveAura, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
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),
aura,
});
}
}
world.set_active_auras(active);
}
/// 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.
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
let mut max = 1;
let mut extra = 0;
for active in world.active_auras() {
let Aura::TriggerMultiplier {
trigger,
times,
stacking,
} = active.aura;
if active.owner != owner || trigger != on {
continue;
}
match stacking {
Stacking::Max => max = max.max(times),
Stacking::Additive => extra += times - 1,
}
}
max + extra
}
@@ -0,0 +1,290 @@
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
//! read straight from JSON. The engine compiles each definition once, when it is
//! registered, into the lists it reads while resolving: triggers, auras and a
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
use std::collections::HashMap;
use serde::Deserialize;
/// What a triggered ability listens for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TriggerOn {
/// This minion takes damage.
SelfDamaged,
/// A minion with the same owner (this one included) takes damage.
FriendlyMinionDamaged,
/// Any minion takes damage.
AnyMinionDamaged,
/// Either hero takes damage.
HeroDamaged,
/// This minion dies. It resolves after the minion has left play.
Deathrattle,
/// This minion is played from hand.
Battlecry,
/// The end of its owner's turn. The course has no turns, so it never fires; card
/// data may still name it, for example in a trigger multiplier.
EndOfTurn,
}
impl TriggerOn {
/// The trigger's family name, as reported in `Triggered` events.
pub fn label(self) -> &'static str {
match self {
TriggerOn::SelfDamaged
| TriggerOn::FriendlyMinionDamaged
| TriggerOn::AnyMinionDamaged
| TriggerOn::HeroDamaged => "damaged",
TriggerOn::Deathrattle => "deathrattle",
TriggerOn::Battlecry => "battlecry",
TriggerOn::EndOfTurn => "endOfTurn",
}
}
}
/// How several trigger multipliers for the same trigger combine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stacking {
/// The largest multiplier wins: two copies of a doubler still double.
Max,
/// Each copy adds its extra activations.
Additive,
}
/// An aura: a continuous effect a minion has while it is in play. The engine reads
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Aura {
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
/// owner, activate `times` times each.
TriggerMultiplier {
trigger: TriggerOn,
times: u32,
stacking: Stacking,
},
}
/// Who an effect touches. `This` is the schema's `"self"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Target {
#[serde(rename = "self")]
This,
AllMinions,
AllOtherMinions,
AllOtherCharacters,
EnemyHero,
Chosen,
}
/// Which minions an Assemble may pick as its base. An empty filter allows any.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PoolFilter {
/// Only minions with a trigger clause listening for this.
#[serde(default)]
pub trigger: Option<TriggerOn>,
}
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Effect {
DealDamage {
target: Target,
amount: i32,
},
Draw {
count: u32,
},
Summon {
card: String,
count: u32,
},
GainArmor {
amount: i32,
},
GainAttack {
target: Target,
amount: i32,
},
Destroy {
target: Target,
},
/// Part III (SpellGrid) only: puts a word on the grid.
SpawnText {
word: String,
at: String,
},
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
Assemble {
filter: PoolFilter,
},
/// Give every minion in the owner's hand +attack/+health.
BuffHand {
attack: i32,
health: i32,
},
}
/// One line of card text. A trigger clause applies to the effect clauses after it,
/// until the next trigger, keyword or aura. Effects before any trigger are what a
/// spell does when cast.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Clause {
Keyword { keyword: String },
Trigger { on: TriggerOn },
Aura { modifier: Aura },
Effect { effect: Effect },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CardType {
Minion,
Spell,
}
/// A card as data, in the course's card format. Fields the engine doesn't use
/// (`fictional`, `game`, `noun`) are ignored.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct CardDef {
pub name: String,
#[serde(rename = "type")]
pub card_type: CardType,
pub cost: i32,
#[serde(default)]
pub attack: i32,
#[serde(default)]
pub health: i32,
pub text: Vec<Clause>,
/// `text[..split]` is the top half, `text[split..]` the bottom half.
pub split: usize,
}
/// A triggered ability: "whenever <on>, <effects>".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trigger {
pub on: TriggerOn,
pub effects: Vec<Effect>,
}
/// A registered card: its definition, and the definition's text compiled into the
/// lists the engine reads while resolving.
#[derive(Debug, Clone)]
pub struct Card {
pub def: CardDef,
/// Triggered abilities, in printed order.
pub triggers: Vec<Trigger>,
/// Auras this minion has while in play.
pub auras: Vec<Aura>,
/// What the card does when cast (a spell's effects).
pub on_cast: Vec<Effect>,
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
pub in_pool: bool,
}
impl Card {
/// Compile a definition's text. Panics if `split` is past the end of the text.
pub fn compile(def: CardDef, in_pool: bool) -> Card {
assert!(
def.split <= def.text.len(),
"{:?}: split {} is past the end of its text",
def.name,
def.split
);
let mut triggers: Vec<Trigger> = Vec::new();
let mut auras = Vec::new();
let mut on_cast = Vec::new();
// Is the latest clause a trigger whose effects are still being listed?
let mut open_trigger = false;
for clause in &def.text {
match clause {
Clause::Trigger { on } => {
triggers.push(Trigger {
on: *on,
effects: Vec::new(),
});
open_trigger = true;
}
Clause::Effect { effect } if open_trigger => {
triggers.last_mut().unwrap().effects.push(effect.clone());
}
Clause::Effect { effect } => on_cast.push(effect.clone()),
Clause::Aura { modifier } => {
auras.push(*modifier);
open_trigger = false;
}
// The course has no combat, so keywords such as Taunt do nothing here.
Clause::Keyword { .. } => open_trigger = false,
}
}
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
Card {
def,
triggers,
auras,
on_cast,
in_pool,
}
}
pub fn is_spell(&self) -> bool {
self.def.card_type == CardType::Spell
}
pub fn has_trigger(&self, on: TriggerOn) -> bool {
self.triggers.iter().any(|t| t.on == on)
}
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
cards: HashMap<String, Card>,
}
impl CardLibrary {
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
let mut library = CardLibrary::default();
for def in defs {
library.register(def);
}
library
}
/// Add a card, or replace the card with the same name. It joins the Assemble pool
/// if it is a minion with text.
pub fn register(&mut self, def: CardDef) {
self.insert(Card::compile(def, true));
}
/// Add a card made by Assemble. It never joins the pool.
pub fn register_assembled(&mut self, def: CardDef) {
self.insert(Card::compile(def, false));
}
fn insert(&mut self, card: Card) {
self.cards.insert(card.def.name.clone(), card);
}
pub fn contains(&self, name: &str) -> bool {
self.cards.contains_key(name)
}
pub fn get(&self, name: &str) -> &Card {
self.cards
.get(name)
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
}
/// The Assemble pool: every card with `in_pool`, sorted by name.
pub fn pool(&self) -> Vec<&Card> {
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
pool
}
}
@@ -0,0 +1,30 @@
//! 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::World;
/// 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 position is read before anything moves: removal is simultaneous, and each
// Death Event remembers where its minion stood when the step began.
let mut deaths = Vec::new();
for id in world.minions_in_play() {
if world.is_doomed(id) {
let position = world
.board_index(id)
.expect("a minion in play is on the board");
deaths.push(Event::Destroyed { id, position });
}
}
for death in &deaths {
if let Event::Destroyed { id, position } = *death {
world.kill(id, position);
}
}
deaths
}
@@ -0,0 +1,91 @@
//! 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::{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 {
targets: targets(*target, ctx),
amount: *amount,
}],
Effect::Draw { count } => (0..*count)
.map(|_| Action::Draw { player: owner })
.collect(),
Effect::Summon { card, count } => (0..*count)
.map(|_| Action::Summon {
card: card.clone(),
player: owner,
at: summon_position(ctx),
})
.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 { .. } => panic!("spawnText needs a grid: it is a Part III effect"),
}
}
/// 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 (its Death Event's position); 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,51 @@
//! What happened, in order. The engine records an event each time it changes the game.
use super::card::TriggerOn;
use super::world::{EntityId, PlayerId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Summoned {
id: EntityId,
},
Damaged {
target: EntityId,
amount: i32,
},
Triggered {
source: EntityId,
on: TriggerOn,
},
Drew {
player: PlayerId,
id: EntityId,
},
Fatigue {
player: PlayerId,
},
ArmorGained {
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.
/// `position` is the board index it had when the step began; its Deathrattle summons there.
Destroyed {
id: EntityId,
position: usize,
},
DeathPhase {
round: u32,
},
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
Played {
id: EntityId,
},
}
@@ -0,0 +1,43 @@
//! 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.
//!
//! 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.
//!
//! 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.
mod assemble;
mod aura;
mod card;
mod death;
mod effect;
mod event;
pub mod puzzle;
mod queue;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
Trigger, TriggerOn,
};
pub use event::Event;
pub use queue::{run_sequence, summon, Action, Ctx};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -0,0 +1,96 @@
//! 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 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>,
}
#[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(),
},
}
}
}
#[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
}
/// Every object, oldest first.
pub fn objects(&self) -> impl Iterator<Item = &Object> {
self.objects.values()
}
}
@@ -0,0 +1,47 @@
//! 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.
use super::grid::{Board, Kind, LevelDef, ObjectId};
use super::parse::{parse, Rule};
use super::word::{Lexicon, WordDef};
#[derive(Debug, Clone)]
pub struct Level {
board: Board,
lexicon: Lexicon,
rules: Vec<Rule>,
}
impl Level {
pub fn new(def: &LevelDef, words: &[WordDef]) -> Level {
let mut level = Level {
board: Board::new(def.width, def.height),
lexicon: Lexicon::new(words),
rules: 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.reparse();
id
}
pub fn board(&self) -> &Board {
&self.board
}
/// Every rule in force, as parsed (duplicates included).
pub fn rules(&self) -> &[Rule] {
&self.rules
}
fn reparse(&mut self) {
self.rules = parse(&self.board, &self.lexicon);
}
}
@@ -0,0 +1,17 @@
//! 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.
mod grid;
mod level;
mod parse;
mod word;
pub use grid::{Board, Kind, LevelDef, LevelObject, Object, ObjectId};
pub use level::Level;
pub use parse::{parse, Rule, BASE_RULES};
pub use word::{lex, Lexicon, Role, Token, WordDef};
@@ -0,0 +1,95 @@
//! The parser: words on the grid become rules.
//!
//! Grammar (Module 6): `NOUN [AND NOUN]* IS (NOUN|PROPERTY) [AND (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.
use std::collections::BTreeMap;
use std::fmt;
use super::grid::{Board, ObjectId};
use super::word::{lex, Lexicon, Role, Token, AND, IS, TEXT};
/// One rule: `subject IS complement`. `text` is the three text objects that spell
/// it (subject, `IS`, complement), or empty for a base rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub subject: String,
pub complement: String,
pub text: Vec<ObjectId>,
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {IS} {}", self.subject, self.complement)
}
}
/// Rules in force with no text: the game's base rules (`(subject, complement)`).
pub const BASE_RULES: &[(&str, &str)] = &[(TEXT, "PUSH")];
type Tiles = BTreeMap<(i32, i32), Vec<Token>>;
/// Every rule the board spells, base rules first. 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, complement)| Rule {
subject: subject.to_string(),
complement: complement.to_string(),
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 subjects = run(&tiles, (x - dx, y - dy), (-dx, -dy), is_subject);
let complements = run(&tiles, (x + dx, y + dy), (dx, dy), is_complement);
for s in &subjects {
for c in &complements {
rules.push(Rule {
subject: s.word.clone(),
complement: c.word.clone(),
text: vec![s.id, is.id, c.id],
});
}
}
}
}
}
rules
}
fn is_subject(t: &Token) -> bool {
t.role == Role::Noun
}
fn is_complement(t: &Token) -> bool {
matches!(t.role, Role::Noun | Role::Property)
}
/// A run of words joined by AND, walking from `at` in `step` direction: the words
/// at `at` that `fits` accepts, then, if the next tile holds an AND, the run that
/// starts on the tile after it. Empty if `at` holds no fitting word.
fn run<'a>(
tiles: &'a Tiles,
at: (i32, i32),
step: (i32, i32),
fits: fn(&Token) -> bool,
) -> Vec<&'a Token> {
let words_at = |p: (i32, i32)| tiles.get(&p).map(Vec::as_slice).unwrap_or(&[]);
let mut found: Vec<&Token> = words_at(at).iter().filter(|t| fits(t)).collect();
if found.is_empty() {
return found;
}
let and_at = (at.0 + step.0, at.1 + step.1);
if words_at(and_at).iter().any(|t| t.word == AND) {
let next = (and_at.0 + step.0, and_at.1 + step.1);
found.extend(run(tiles, next, step, fits));
}
found
}
@@ -0,0 +1,84 @@
//! 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.
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,
}
/// One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`.
#[derive(Debug, Clone, Deserialize)]
pub struct WordDef {
pub name: String,
pub role: Role,
}
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>,
}
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();
for op in [IS, AND, NOT] {
roles.insert(op.to_string(), Role::Operator);
}
roles.insert(TEXT.to_string(), Role::Noun);
Lexicon { roles }
}
/// `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()
}
}
/// 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,285 @@
//! 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 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.
use super::assemble::assemble_from_pool;
use super::aura::{activations, aura_update_other};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
use super::event::Event;
use super::world::{EntityId, PlayerId, World, Zone};
/// Something the engine can do. Card effects return these instead of changing the world.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Deal `amount` to each target at once: all damage first, then the reactions.
Damage {
targets: Vec<EntityId>,
amount: i32,
},
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,
},
}
/// 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 the position its Death Event remembered.
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)
}
}
/// 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;
world.record(Event::DeathPhase { round });
react(world, &deaths);
deaths = end_of_outermost_phase(world);
}
}
/// The steps that run only when the outermost Phase ends; 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 deaths = death_creation_step(world);
// After the step: a minion that just died no longer contributes its auras
// to the Death Phase that resolves these deaths.
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 targets,
amount,
} => {
// Area damage: every Damage Event is created, in order of play, before any reaction.
targets.sort_by_key(|&id| world.order_of_play(id));
let mut events = Vec::new();
for target in targets {
world.damage(target, amount);
events.push(Event::Damaged { target, amount });
}
react(world, &events);
}
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),
}
}
/// 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);
}
}
}
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
let mut queue = Vec::new();
for event in events {
for listener in listeners(world, event) {
for trigger in &world.card(world.card_name(listener)).triggers {
if responds(world, trigger.on, listener, event) {
queue.push((listener, trigger.clone(), event));
}
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (source, 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, position } if id == source => Some(position),
_ => world.board_index(source),
};
let owner = world.owner_of(source);
run_effects(world, &trigger.effects, owner, Some(source), None, position);
}
}
}
/// 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;
}
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,378 @@
//! 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;
/// 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 player's minion has it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
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 auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
auras: Vec<ActiveAura>,
}
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(),
auras: Vec::new(),
};
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);
self.record(Event::Summoned { id });
}
}
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
}
pub fn log(&self) -> &[Event] {
&self.log
}
// ---- Changes: each one records its event ----------------------------
pub fn record(&mut self, event: Event) {
self.log.push(event);
}
/// 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
}
/// 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();
}
pub fn damage(&mut self, target: EntityId, amount: i32) {
self.entity_mut(target).health -= amount;
self.record(Event::Damaged { target, 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, position: usize) {
self.entity_mut(id).pending_destroy = false;
self.move_to(id, Zone::Graveyard);
self.record(Event::Destroyed { id, position });
}
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 });
}
}
@@ -0,0 +1,223 @@
//! Test harness: the thin adapter the course tests call. It translates each
//! call into engine operations and holds no rules of its own.
use crate::content;
use crate::engine::{self, puzzle, run_sequence, summon, Action, CardLibrary, Event, World};
pub use crate::engine::{AssembleError, CardDef, Half, Zone};
pub type Id = u32;
pub type Player = u8;
#[derive(Debug, Default, Clone)]
pub struct GameOpts {
pub decks: Option<[Vec<String>; 2]>,
}
/// The events the tests read from `log()` (see course/harness.md).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GameEvent {
Summoned { id: Id },
Damaged { target: Id, amount: i32 },
Triggered { source: Id, trigger: String },
Destroyed { id: Id },
DeathPhase { round: u32 },
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
impl GameEvent {
/// The course's view of an engine event; `None` for engine events the course log
/// doesn't show.
fn from_engine(event: &Event) -> Option<GameEvent> {
Some(match event.clone() {
Event::Summoned { id } => GameEvent::Summoned { id },
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
Event::Triggered { source, on } => GameEvent::Triggered {
source,
trigger: on.label().to_string(),
},
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
Event::SpellCast { card } => GameEvent::SpellCast { card },
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
// A played minion's `Summoned` already shows it entering the board.
Event::Played { .. } => return None,
})
}
}
pub struct Game {
world: World,
}
pub fn new_game(opts: GameOpts) -> Game {
let mut world = World::new(CardLibrary::new(content::cards()));
if let Some(decks) = opts.decks {
for (player, deck) in decks.iter().enumerate() {
for card in deck {
world.create(card, player as Player, Zone::Deck);
}
}
}
Game { world }
}
impl Game {
/// Put `card` at the right end of `player`'s board.
pub fn summon(&mut self, card: &str, player: Player) -> Id {
summon(&mut self.world, card, player, None)
}
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Hand)
}
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
self.world.create(card, player, Zone::Deck)
}
/// Play a card from hand, as one Sequence: it enters the right end of the board, then
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
pub fn play(&mut self, hand_id: Id) {
run_sequence(&mut self.world, Action::Play { id: hand_id });
}
pub fn zone_of(&self, id: Id) -> Zone {
self.world.zone_of(id)
}
/// Board ids, left to right.
pub fn board(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Board).to_vec()
}
pub fn hand(&self, player: Player) -> Vec<Id> {
self.world.zone(player, Zone::Hand).to_vec()
}
pub fn order_of_play(&self, id: Id) -> u32 {
self.world
.order_of_play(id)
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
}
pub fn name_of(&self, id: Id) -> String {
self.world.card_name(id).to_string()
}
// ---- Module 2 ----
pub fn hero(&self, player: Player) -> Id {
self.world.hero(player)
}
/// Deal damage to one character, as one Sequence.
pub fn deal_damage(&mut self, target: Id, amount: i32) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Damage { targets, amount });
}
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
let card = card.to_string();
run_sequence(
&mut self.world,
Action::Cast {
card,
player,
target,
},
);
}
pub fn health(&self, id: Id) -> i32 {
self.world.health(id)
}
pub fn attack(&self, id: Id) -> i32 {
self.world.attack(id)
}
pub fn armor(&self, player: Player) -> i32 {
self.world.armor(player)
}
pub fn log(&self) -> Vec<GameEvent> {
self.world
.log()
.iter()
.filter_map(GameEvent::from_engine)
.collect()
}
pub fn clear_log(&mut self) {
self.world.clear_log();
}
// ---- Module 3 ----
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
pub fn destroy(&mut self, target: Id) {
let targets = vec![target];
run_sequence(&mut self.world, Action::Destroy { targets });
}
// ---- Module 5 ----
/// Add card definitions to this game, replacing any with the same name.
pub fn register_cards(&mut self, defs: Vec<CardDef>) {
for def in defs {
self.world.register(def);
}
}
/// Register `base` with `half` of its text replaced by the same half of `donor`,
/// and return the new card's name, `"<base> (Assembled)"`.
pub fn assemble(
&mut self,
base: &str,
donor: &str,
half: Half,
) -> Result<String, AssembleError> {
let base = &self.world.card(base).def;
let donor = &self.world.card(donor).def;
let def = engine::assemble(base, donor, half)?;
Ok(engine::register_assembled(&mut self.world, def))
}
pub fn cost(&self, id: Id) -> i32 {
self.world.cost(id)
}
/// Who makes choices (Assemble's base, donor and half): called with the kind of
/// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them.
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.world.set_chooser(Box::new(chooser));
}
}
// ======== Part II: Baba Is You ========
pub use crate::engine::puzzle::{LevelDef, LevelObject};
/// A Baba Is You level in play (Part II).
pub struct Baba {
level: puzzle::Level,
}
/// A level from a level definition; its text is parsed straight away.
pub fn new_baba(level: LevelDef) -> Baba {
Baba {
level: puzzle::Level::new(&level, &content::words()),
}
}
impl Baba {
// ---- Module 6 ----
/// Add an object (`"baba"`) or a piece of text (`"text:BABA"`) at (x, y).
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id {
self.level.place(kind, x, y)
}
/// Every distinct rule in force, normalised (`"BABA IS YOU"`) and sorted.
pub fn rules(&self) -> Vec<String> {
let mut rules: Vec<String> = self.level.rules().iter().map(|r| r.to_string()).collect();
rules.sort();
rules.dedup();
rules
}
}
@@ -0,0 +1,3 @@
pub mod content;
pub mod engine;
pub mod harness;