feat: module 10 spellgrid

SpellGrid capstone: Part I's card engine and Part II's grid engine joined at
three seams (rules -> aura data, hazard victims -> one destroy Sequence,
Sequence end -> write text, remove the dead, reparse). Three levels with
scripted solutions, a naive-timing test via a harness-only hook that
reverses the END_OF_PHASE list, CLIs in both tracks, the BATTLECRY IS DOUBLE
Expansion Drop, SpellGrid cards and words in course/cards and the catalog,
level schema cards/hand, and tools staging/copying of module levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 05:01:27 +01:00
1 parent 1f3359cfe2
commit a0cf9be214
96 files changed
+10454 -8

No files matched your search

@@ -0,0 +1 @@
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "10" }
@@ -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"
@@ -0,0 +1,20 @@
# Module 10 reference engine (Rust)
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
- `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
- Part I (`src/engine/`, outside `puzzle/`):
- `queue.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event.
- `world.rs`: `set_rule_auras`/`rule_auras` (auras that no minion carries), `set_end_of_phase`/`end_of_phase`, `entity_count`.
- `aura.rs`: an Aura Update adds the rule auras for both players; `activations` applies an `Aura::TriggerCap`.
- `card.rs`: `Aura::TriggerCap` (not in the card format: only SpellGrid makes it), and `CardDef::noun`.
- `effect.rs`: `spawnText` is an action, not a panic. `event.rs`: `TextSpawned`.
- Part II (`src/engine/puzzle/`):
- `level.rs`: `Destroy` and `remove_all`. `step_with` and `move_you_with` take a destroy function, which movement, conversion (`X IS NOT X`) and hazards hand their victims to; `step` and `move_you` pass `remove_all`. `reparse` and `board_mut` are public.
- `grid.rs`: `Board::reserve_id`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid), with `LevelCard`.
- `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`.
- `src/harness.rs`: Module 10 adds `new_spell_grid` and `SpellGrid`, including the test hook `with_naive_aura_timing()`, which reverses `END_OF_PHASE` for one game.
- `src/cli.rs` and `src/bin/spellgrid.rs` (new): the command line, `cargo run --bin spellgrid -- levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c <card> [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.rs` plays it from a script; it is the reference's own check, not a course test.
Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository.
@@ -0,0 +1,21 @@
//! SpellGrid on the command line. See `src/cli.rs`.
//!
//! `cargo run --bin spellgrid -- levels/l1-double-tap.json`
use std::io::{self, BufReader};
use engine::cli::{run, Outcome};
fn main() {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: cargo run --bin spellgrid -- <level.json>");
std::process::exit(2);
};
let level = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("cannot read {path}: {e}");
std::process::exit(2);
});
let input = Box::new(BufReader::new(io::stdin()));
let outcome = run(&level, input, Box::new(io::stdout()));
std::process::exit(if outcome == Outcome::Won { 0 } else { 1 });
}
@@ -0,0 +1,199 @@
//! SpellGrid on the command line: `cargo run --bin spellgrid -- levels/l1-double-tap.json`.
//!
//! A thin loop over the harness: it draws the level, reads a command, and calls
//! `step`, `cast` or `play`. No rules live here.
//!
//! ```text
//! w a s d one turn: up, left, down, right
//! c <card> [x y] cast a spell from your hand (at the minion on (x, y), if given),
//! or play a minion from your hand onto the empty tile (x, y)
//! q quit
//! ```
//!
//! Assemble's choices (Graft) are asked for one at a time: answer with a number or a name.
use std::cell::RefCell;
use std::io::{BufRead, Write};
use std::panic::{self, AssertUnwindSafe};
use std::rc::Rc;
use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid};
/// How a game ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Won,
Lost,
Quit,
}
/// The player's input and the screen, shared with the chooser.
struct Io {
input: Box<dyn BufRead>,
output: Box<dyn Write>,
}
impl Io {
fn read_line(&mut self) -> Option<String> {
let mut line = String::new();
match self.input.read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()),
}
}
fn write(&mut self, s: &str) {
let _ = self.output.write_all(s.as_bytes());
let _ = self.output.flush();
}
}
/// One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals.
fn cell(g: &SpellGrid, x: i32, y: i32) -> String {
let kinds = g.kinds_at(x, y);
// `kinds_at` is sorted; prefer text, which is what the player reads.
let Some(kind) = kinds
.iter()
.find(|k| k.starts_with("text:"))
.or(kinds.first())
else {
return " . ".to_string();
};
let name = match kind.strip_prefix("text:") {
Some(word) => word.to_uppercase(),
None => kind.to_lowercase(),
};
format!("{:<3}", name.chars().take(3).collect::<String>())
}
/// The level as text: the grid, the rules, the minions and the hand.
pub fn render(g: &SpellGrid, level: &LevelDef) -> String {
let mut lines = Vec::new();
for y in 0..level.height {
let row: Vec<String> = (0..level.width).map(|x| cell(g, x, y)).collect();
lines.push(row.join(" "));
}
lines.push(format!("Rules: {}", g.active_rules().join(", ")));
let mut minions = Vec::new();
for y in 0..level.height {
for x in 0..level.width {
if let Some(id) = g.minion_at(x, y) {
let (name, health) = (g.name_of(id), g.health(id));
minions.push(format!("{name} ({x},{y}) {health} Health"));
}
}
}
let or = |list: Vec<String>, none: &str| {
if list.is_empty() {
none.to_string()
} else {
list.join(", ")
}
};
lines.push(format!(
"Minions: {}",
or(minions, "none").replace(", ", "; ")
));
lines.push(format!("Hand: {}", or(g.hand(), "empty")));
lines.join("\n")
}
/// A `c` command: `c <card words...> [x y]`.
fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) {
let n = args.len();
if n >= 3 {
if let (Ok(x), Ok(y)) = (args[n - 2].parse(), args[n - 1].parse()) {
return (args[..n - 2].join(" "), Some((x, y)));
}
}
(args.join(" "), None)
}
/// Play `level_text` (a level in the course's format), reading commands from `input`
/// and writing to `output`.
pub fn run(level_text: &str, input: Box<dyn BufRead>, output: Box<dyn Write>) -> Outcome {
let level: LevelDef =
serde_json::from_str(level_text).expect("a level in the course's level format");
let mut g = new_spell_grid(level.clone(), vec![]);
let io = Rc::new(RefCell::new(Io { input, output }));
let chooser_io = Rc::clone(&io);
g.set_chooser(move |kind, options| {
let mut io = chooser_io.borrow_mut();
let listed: Vec<String> = options
.iter()
.enumerate()
.map(|(i, o)| format!(" {}) {o}", i + 1))
.collect();
io.write(&format!(
"Choose the {kind} (a number or a name):\n{}\n? ",
listed.join("\n")
));
let answer = io.read_line().unwrap_or_default().trim().to_string();
if options.contains(&answer) {
return answer;
}
let picked = answer
.parse::<usize>()
.ok()
.and_then(|n| options.get(n.wrapping_sub(1)));
picked.unwrap_or(&options[0]).clone()
});
// The harness panics on a move the rules don't allow (a card not in the hand, a
// full tile). The CLI reports the message instead of stopping.
let hook = panic::take_hook();
panic::set_hook(Box::new(|_| {}));
let outcome = play(&mut g, &level, &io);
panic::set_hook(hook);
outcome
}
/// Draw, read a command, do it; until the level is won or lost, or the player quits.
fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc<RefCell<Io>>) -> Outcome {
loop {
io.borrow_mut().write(&format!("{}\n", render(g, level)));
if g.won() {
io.borrow_mut().write("You win!\n");
return Outcome::Won;
}
if g.lost() {
io.borrow_mut().write("Nothing is YOU. You lose.\n");
return Outcome::Lost;
}
io.borrow_mut().write("> ");
let Some(line) = io.borrow_mut().read_line() else {
return Outcome::Quit;
};
let words: Vec<&str> = line.split_whitespace().collect();
let dir = match words.first() {
Some(&"q") => return Outcome::Quit,
Some(&"w") => Some(Dir::Up),
Some(&"a") => Some(Dir::Left),
Some(&"s") => Some(Dir::Down),
Some(&"d") => Some(Dir::Right),
_ => None,
};
let result = panic::catch_unwind(AssertUnwindSafe(|| match (dir, words.as_slice()) {
(Some(dir), _) => g.step(dir),
(None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) {
(card, None) => g.cast(&card, None),
(card, Some((x, y))) => match g.minion_at(x, y) {
Some(target) => g.cast(&card, Some(target)),
None => {
g.play(&card, x, y);
}
},
},
_ => io
.borrow_mut()
.write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n"),
}));
if let Err(payload) = result {
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_else(|| "that can't be done".to_string());
io.borrow_mut().write(&format!("{message}\n"));
}
}
}
@@ -0,0 +1,54 @@
//! Part II: what destroys what at the end of a turn. The engine runs these
//! hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT,
//! then DEFEAT), and destroys each one's victims together before the next runs.
//! A hazard only reaches objects on the same float level: it asks the level what
//! shares a tile (`Level::sharing`). A new hazard property is one more function here.
use crate::engine::puzzle::{Hazard, Level, ObjectId};
/// The hazards, in the order the engine runs them.
pub fn hazards() -> Vec<Hazard> {
vec![sink, hot_melt, defeat]
}
/// The objects that have `property`, oldest first.
fn with(level: &Level, property: &str) -> Vec<ObjectId> {
level
.board()
.objects()
.map(|o| o.id)
.filter(|&id| level.has_property(id, property))
.collect()
}
/// A SINK object that shares its tile with anything destroys all of it, and itself.
fn sink(level: &Level) -> Vec<ObjectId> {
let mut doomed = vec![];
for id in with(level, "SINK") {
let others = level.sharing(id);
if !others.is_empty() {
doomed.push(id);
doomed.extend(others);
}
}
doomed
}
/// A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one.
fn hot_melt(level: &Level) -> Vec<ObjectId> {
destroyed_by(level, "MELT", "HOT")
}
/// A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one.
fn defeat(level: &Level) -> Vec<ObjectId> {
destroyed_by(level, "YOU", "DEFEAT")
}
/// The objects with `victim` that have `killer` too, or share a tile with one that does.
fn destroyed_by(level: &Level, victim: &str, killer: &str) -> Vec<ObjectId> {
let deadly = |id: ObjectId| level.has_property(id, killer);
with(level, victim)
.into_iter()
.filter(|&id| deadly(id) || level.sharing(id).into_iter().any(deadly))
.collect()
}
@@ -0,0 +1,60 @@
//! 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;
mod hazards;
mod movement;
pub use hazards::hazards;
pub use movement::responses as movement;
/// 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 III: the words that cross between the engines (`seams.json`). A noun that
/// names a trigger, and a property that multiplies one: `DEATHRATTLE IS DOUBLE`.
pub fn seams() -> crate::engine::SeamWords {
serde_json::from_str(include_str!("seams.json"))
.expect("src/content/seams.json is not a seam word table")
}
/// 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,44 @@
//! Part II: what each property does when an object moves. The engine plans a move
//! one step at a time and runs these responses for every step, in this order (the
//! order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new
//! movement property is one more response here.
use crate::engine::puzzle::{Planner, Response, Step};
/// The movement responses, in the order the engine runs them.
pub fn responses() -> Vec<Response> {
vec![open_shut, push, stop]
}
/// An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one)
/// destroys both, and that object doesn't block the step. One partner per step:
/// the oldest.
fn open_shut(p: &mut Planner, s: Step) -> bool {
for (mine, theirs) in [("OPEN", "SHUT"), ("SHUT", "OPEN")] {
if !p.has(s.mover, mine) {
continue;
}
let partner = p.staying_at(s.to).into_iter().find(|&o| p.has(o, theirs));
if let Some(partner) = partner {
p.destroy(partner);
p.destroy(s.mover);
return true;
}
}
true
}
/// Every PUSH object on the tile ahead must step the same way, or the step fails.
fn push(p: &mut Planner, s: Step) -> bool {
for o in p.staying_at(s.to) {
if p.has(o, "PUSH") && !p.try_move(o) {
return false;
}
}
true
}
/// A STOP object still on the tile ahead blocks the step.
fn stop(p: &mut Planner, s: Step) -> bool {
!p.staying_at(s.to).into_iter().any(|o| p.has(o, "STOP"))
}
@@ -0,0 +1,4 @@
{
"triggerWords": { "DEATHRATTLE": "deathrattle" },
"multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } }
}
@@ -0,0 +1,31 @@
[
{ "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": "KEG", "role": "noun" },
{ "name": "BRAMBLE", "role": "noun" },
{ "name": "HEDGE", "role": "noun" },
{ "name": "WARDEN", "role": "noun" },
{ "name": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" },
{ "name": "OPEN", "role": "property" },
{ "name": "SHUT", "role": "property" },
{ "name": "HOT", "role": "property" },
{ "name": "MELT", "role": "property" },
{ "name": "SINK", "role": "property" },
{ "name": "DEFEAT", "role": "property" },
{ "name": "FLOAT", "role": "property" },
{ "name": "DOUBLE", "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,67 @@
//! 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.
//!
//! 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, 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),
aura,
});
}
}
for &aura in world.rule_auras() {
for owner in 0..2 {
active.push(ActiveAura { owner, 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. 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,297 @@
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
//! read straight from JSON. The engine compiles each definition once, when it is
//! registered, into the lists it reads while resolving: triggers, auras and a
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
use std::collections::HashMap;
use serde::Deserialize;
/// What a triggered ability listens for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TriggerOn {
/// This minion takes damage.
SelfDamaged,
/// A minion with the same owner (this one included) takes damage.
FriendlyMinionDamaged,
/// Any minion takes damage.
AnyMinionDamaged,
/// Either hero takes damage.
HeroDamaged,
/// This minion dies. It resolves after the minion has left play.
Deathrattle,
/// This minion is played from hand.
Battlecry,
/// The end of its owner's turn. The course has no turns, so it never fires; card
/// data may still name it, for example in a trigger multiplier.
EndOfTurn,
}
impl TriggerOn {
/// The trigger's family name, as reported in `Triggered` events.
pub fn label(self) -> &'static str {
match self {
TriggerOn::SelfDamaged
| TriggerOn::FriendlyMinionDamaged
| TriggerOn::AnyMinionDamaged
| TriggerOn::HeroDamaged => "damaged",
TriggerOn::Deathrattle => "deathrattle",
TriggerOn::Battlecry => "battlecry",
TriggerOn::EndOfTurn => "endOfTurn",
}
}
}
/// How several trigger multipliers for the same trigger combine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stacking {
/// The largest multiplier wins: two copies of a doubler still double.
Max,
/// Each copy adds its extra activations.
Additive,
}
/// An aura: a continuous effect a minion has while it is in play. The engine reads
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Aura {
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
/// owner, activate `times` times each.
TriggerMultiplier {
trigger: TriggerOn,
times: u32,
stacking: Stacking,
},
/// Module 10: triggers listening for `trigger` activate at most `times` times,
/// whatever multiplies them. Not in the card format: SpellGrid compiles it from a
/// negated rule (`DEATHRATTLE IS NOT DOUBLE`).
TriggerCap { trigger: TriggerOn, times: u32 },
}
/// Who an effect touches. `This` is the schema's `"self"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Target {
#[serde(rename = "self")]
This,
AllMinions,
AllOtherMinions,
AllOtherCharacters,
EnemyHero,
Chosen,
}
/// Which minions an Assemble may pick as its base. An empty filter allows any.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct PoolFilter {
/// Only minions with a trigger clause listening for this.
#[serde(default)]
pub trigger: Option<TriggerOn>,
}
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Effect {
DealDamage {
target: Target,
amount: i32,
},
Draw {
count: u32,
},
Summon {
card: String,
count: u32,
},
GainArmor {
amount: i32,
},
GainAttack {
target: Target,
amount: i32,
},
Destroy {
target: Target,
},
/// Part III (SpellGrid) only: puts a word on the grid.
SpawnText {
word: String,
at: String,
},
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
Assemble {
filter: PoolFilter,
},
/// Give every minion in the owner's hand +attack/+health.
BuffHand {
attack: i32,
health: i32,
},
}
/// One line of card text. A trigger clause applies to the effect clauses after it,
/// until the next trigger, keyword or aura. Effects before any trigger are what a
/// spell does when cast.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Clause {
Keyword { keyword: String },
Trigger { on: TriggerOn },
Aura { modifier: Aura },
Effect { effect: Effect },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CardType {
Minion,
Spell,
}
/// A card as data, in the course's card format. Fields the engine doesn't use
/// (`fictional`, `game`) are ignored. `noun` is read by SpellGrid (Module 10).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct CardDef {
pub name: String,
#[serde(rename = "type")]
pub card_type: CardType,
pub cost: i32,
#[serde(default)]
pub attack: i32,
#[serde(default)]
pub health: i32,
pub text: Vec<Clause>,
/// `text[..split]` is the top half, `text[split..]` the bottom half.
pub split: usize,
/// The noun a SpellGrid minion has on the grid (Module 10).
#[serde(default)]
pub noun: Option<String>,
}
/// A triggered ability: "whenever <on>, <effects>".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trigger {
pub on: TriggerOn,
pub effects: Vec<Effect>,
}
/// A registered card: its definition, and the definition's text compiled into the
/// lists the engine reads while resolving.
#[derive(Debug, Clone)]
pub struct Card {
pub def: CardDef,
/// Triggered abilities, in printed order.
pub triggers: Vec<Trigger>,
/// Auras this minion has while in play.
pub auras: Vec<Aura>,
/// What the card does when cast (a spell's effects).
pub on_cast: Vec<Effect>,
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
pub in_pool: bool,
}
impl Card {
/// Compile a definition's text. Panics if `split` is past the end of the text.
pub fn compile(def: CardDef, in_pool: bool) -> Card {
assert!(
def.split <= def.text.len(),
"{:?}: split {} is past the end of its text",
def.name,
def.split
);
let mut triggers: Vec<Trigger> = Vec::new();
let mut auras = Vec::new();
let mut on_cast = Vec::new();
// Is the latest clause a trigger whose effects are still being listed?
let mut open_trigger = false;
for clause in &def.text {
match clause {
Clause::Trigger { on } => {
triggers.push(Trigger {
on: *on,
effects: Vec::new(),
});
open_trigger = true;
}
Clause::Effect { effect } if open_trigger => {
triggers.last_mut().unwrap().effects.push(effect.clone());
}
Clause::Effect { effect } => on_cast.push(effect.clone()),
Clause::Aura { modifier } => {
auras.push(*modifier);
open_trigger = false;
}
// The course has no combat, so keywords such as Taunt do nothing here.
Clause::Keyword { .. } => open_trigger = false,
}
}
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
Card {
def,
triggers,
auras,
on_cast,
in_pool,
}
}
pub fn is_spell(&self) -> bool {
self.def.card_type == CardType::Spell
}
pub fn has_trigger(&self, on: TriggerOn) -> bool {
self.triggers.iter().any(|t| t.on == on)
}
}
/// Every card the game can create, looked up by name.
#[derive(Debug, Default)]
pub struct CardLibrary {
cards: HashMap<String, Card>,
}
impl CardLibrary {
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
let mut library = CardLibrary::default();
for def in defs {
library.register(def);
}
library
}
/// Add a card, or replace the card with the same name. It joins the Assemble pool
/// if it is a minion with text.
pub fn register(&mut self, def: CardDef) {
self.insert(Card::compile(def, true));
}
/// Add a card made by Assemble. It never joins the pool.
pub fn register_assembled(&mut self, def: CardDef) {
self.insert(Card::compile(def, false));
}
fn insert(&mut self, card: Card) {
self.cards.insert(card.def.name.clone(), card);
}
pub fn contains(&self, name: &str) -> bool {
self.cards.contains_key(name)
}
pub fn get(&self, name: &str) -> &Card {
self.cards
.get(name)
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
}
/// The Assemble pool: every card with `in_pool`, sorted by name.
pub fn pool(&self) -> Vec<&Card> {
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
pool
}
}
@@ -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,94 @@
//! 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 { 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 (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,58 @@
//! 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,
},
/// 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,55 @@
//! 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.
//! 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.
//!
//! 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 death;
mod effect;
mod event;
pub mod puzzle;
mod queue;
mod spellgrid;
mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use aura::aura_update_other;
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, EndOfPhaseStep, END_OF_PHASE};
pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
@@ -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,293 @@
//! 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.
//!
//! 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.
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, WordDef};
#[derive(Debug, Clone)]
pub struct Level {
board: Board,
lexicon: Lexicon,
responses: Vec<Response>,
hazards: Vec<Hazard>,
rules: Vec<Rule>,
active: Vec<Rule>,
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, and content's
/// movement responses and hazards, each in the order they are to run.
pub fn new(
def: &LevelDef,
words: &[WordDef],
responses: Vec<Response>,
hazards: Vec<Hazard>,
) -> Level {
let mut level = Level {
board: Board::new(def.width, def.height),
lexicon: Lexicon::new(words),
responses,
hazards,
rules: vec![],
active: vec![],
won: false,
};
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
}
/// 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 gives it to a noun
/// that names the object and no active negated rule takes it from one. A
/// destroyed object has no properties.
pub fn has_property(&self, id: ObjectId, property: &str) -> bool {
let Some(object) = self.board.find(id) else {
return false;
};
let kind = &object.kind;
let about_it: Vec<&Rule> = self
.active
.iter()
.filter(|r| r.complement == property && self.lexicon.names(&r.subject, kind))
.collect();
about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated)
}
/// 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.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) {
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(),
}
}
}
/// 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.
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();
for (_, id) in you {
if self.board.find(id).is_none() {
continue; // destroyed by an earlier move
}
let mut planner = Planner::new(self, dir);
if planner.try_move(id) {
let plan = planner.into_plan();
self.commit(&plan, dir, destroy);
}
}
}
/// 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`.
///
/// 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.
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.lexicon.names(&r.subject, &o.kind))
.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()) {
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,40 @@
//! 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.
mod grid;
mod level;
mod movement;
mod parse;
mod resolve;
mod turn;
mod word;
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, 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,134 @@
//! 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.
pub struct Planner<'a> {
level: &'a Level,
dir: Dir,
plan: Plan,
}
impl<'a> Planner<'a> {
pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> {
Planner {
level,
dir,
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.
pub fn try_move(&mut self, id: ObjectId) -> bool {
if self.plan.moving.contains(&id) || self.plan.destroyed.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,166 @@
//! 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.
//!
//! The parser only reads; which rules win is the resolution pass (`resolve`).
use std::collections::BTreeMap;
use std::fmt;
use super::grid::{Board, ObjectId};
use super::word::{lex, Lexicon, Role, Token, AND, IS, NOT, TEXT};
/// One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`.
/// `text` is the text objects that spell it (subject, `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 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 and complement, opposite sign: `X IS NOT Y` negates `X IS Y`.
pub fn negates(&self, other: &Rule) -> bool {
self.negated
&& !other.negated
&& self.subject == other.subject
&& self.complement == other.complement
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let not = if self.negated { " NOT" } else { "" };
write!(f, "{} {IS}{not} {}", self.subject, 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,
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 = subjects(&tiles, (x - dx, y - dy), (-dx, -dy));
let complements = complements(&tiles, (x + dx, y + dy), (dx, dy));
for s in &subjects {
for c in &complements {
let mut text = vec![s.id, 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(),
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)
}
/// 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)) -> Vec<&Token> {
let before = ahead(at, step);
if holds(tiles, before, NOT) {
return vec![];
}
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) {
found.extend(subjects(tiles, ahead(before, step), step));
}
found
}
/// 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,33 @@
//! 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`.
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 = |subject: &str| {
survives_negation
.iter()
.any(|r| !r.negated && r.is_identity() && r.subject == subject)
};
survives_negation
.iter()
.filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(&r.subject))
.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,130 @@
//! 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.
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,
}
/// 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,314 @@
//! 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.
//!
//! 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.
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,
},
/// Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it.
SpawnText {
source: EntityId,
word: String,
},
}
/// 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];
/// 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, 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 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),
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);
}
}
}
/// 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,323 @@
//! 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 three 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.
//!
//! 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, Stacking, TriggerOn};
use super::event::Event;
use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef};
use super::queue::{run_sequence, Action};
use super::world::{EntityId, World, Zone};
/// A multiplier word's effect: `"DOUBLE": { "times": 2, "stacking": "max" }`.
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Multiplier {
pub times: u32,
pub stacking: Stacking,
}
/// The words that cross the seam, from content: nouns that name a trigger
/// (`"DEATHRATTLE": "deathrattle"`) and properties that multiply one.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeamWords {
pub trigger_words: HashMap<String, TriggerOn>,
pub multiplier_words: HashMap<String, Multiplier>,
}
/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a
/// multiplier word) multiplies T's triggers; `T IS NOT M` caps them at one activation,
/// so it cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`.
pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec<Aura> {
let mut auras = Vec::new();
for rule in rules {
let (Some(&trigger), Some(&m)) = (
words.trigger_words.get(&rule.subject),
words.multiplier_words.get(&rule.complement),
) else {
continue;
};
auras.push(if rule.negated {
Aura::TriggerCap { trigger, times: 1 }
} else {
Aura::TriggerMultiplier {
trigger,
times: m.times,
stacking: m.stacking,
}
});
}
auras
}
/// A SpellGrid level in play: a grid and a game, kept in step. Ids are grid ids (see above).
pub struct SpellGrid {
level: Level,
side: CardSide,
}
/// Part I's half: the game, and which entity is which id. Kept apart from the level so
/// that the level can lend itself to the destroy function while this half changes.
struct CardSide {
world: World,
/// Every entity's id.
ids: HashMap<EntityId, ObjectId>,
/// The entity behind each id (a card's old id still finds it after it is played).
entities: HashMap<ObjectId, EntityId>,
seam_words: SeamWords,
}
impl SpellGrid {
/// The level's objects; its `cards` as minions in play for player 0, each a grid
/// object named by the card's noun; its `hand` in player 0's hand.
pub fn new(
def: &LevelDef,
library: CardLibrary,
words: &[WordDef],
responses: Vec<Response>,
hazards: Vec<Hazard>,
seam_words: SeamWords,
) -> SpellGrid {
let mut level = Level::new(def, words, responses, hazards);
let mut side = CardSide {
world: World::new(library),
ids: HashMap::new(),
entities: HashMap::new(),
seam_words,
};
for c in &def.cards {
let kind = side.noun_of(&c.card);
let id = level.board_mut().place(kind, c.x, c.y);
let entity = side.world.create(&c.card, 0, Zone::Board);
side.link(entity, id);
}
// Every minion is in play: one Aura Update, as for a summon.
aura_update_other(&mut side.world);
for card in &def.hand {
side.world.create(card, 0, Zone::Hand);
}
side.link_the_rest(&mut level);
side.grid_changed(&mut level);
SpellGrid { level, side }
}
pub fn level(&self) -> &Level {
&self.level
}
pub fn world(&self) -> &World {
&self.side.world
}
pub fn world_mut(&mut self) -> &mut World {
&mut self.side.world
}
// ---- Ids ----
/// The id of entity `entity`.
pub fn id_of(&self, entity: EntityId) -> ObjectId {
self.side.ids[&entity]
}
/// The entity behind `id`. Panics if `id` is not a card's.
pub fn entity(&self, id: ObjectId) -> EntityId {
self.side.entity(id)
}
/// The id of the minion on tile (x, y), if there is one.
pub fn minion_at(&self, x: i32, y: i32) -> Option<ObjectId> {
self.level
.board()
.at(x, y)
.map(|o| o.id)
.find(|id| self.side.entities.contains_key(id))
}
// ---- What the player does ----
/// Add an object or a piece of text, then reparse.
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
let id = self.level.place(kind, x, y);
self.side.grid_changed(&mut self.level);
id
}
/// One whole turn (Module 9); what it destroys goes through seam 2.
pub fn step(&mut self, dir: Dir) {
let side = &mut self.side;
self.level
.step_with(dir, &mut |level, ids| side.destroy(level, ids));
self.side.grid_changed(&mut self.level);
}
/// Movement only (Module 8), then a reparse.
pub fn move_dir(&mut self, dir: Dir) {
let side = &mut self.side;
self.level
.move_you_with(dir, &mut |level, ids| side.destroy(level, ids));
self.side.grid_changed(&mut self.level);
}
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
pub fn cast(&mut self, card: &str, target: Option<ObjectId>) {
let in_hand = self.side.from_hand(card, CardType::Spell);
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.
pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId {
let in_hand = self.side.from_hand(card, CardType::Minion);
assert!(
self.level.board().at(x, y).next().is_none(),
"({x}, {y}) is not empty"
);
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 from_hand(&self, card: &str, card_type: CardType) -> EntityId {
let in_hand = self
.world
.zone(0, Zone::Hand)
.iter()
.copied()
.find(|&id| self.world.card_name(id) == card)
.unwrap_or_else(|| panic!("{card:?} is not in the hand"));
assert!(
self.world.card(card).def.card_type == card_type,
"{card:?} is not a {card_type:?}"
);
in_hand
}
/// 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 seam 3: 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: 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);
}
}
}
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,411 @@
//! 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 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>,
/// 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(),
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);
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
}
/// 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
}
// ---- 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
}
/// 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();
}
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,446 @@
//! 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, SpellGrid as Grid, World,
END_OF_PHASE,
};
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,
// SpellGrid writes the text on the grid; the course's log doesn't show the request.
Event::TextSpawned { .. } => 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::{Dir, LevelCard, 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(),
content::movement(),
content::hazards(),
),
}
}
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 the text spells, before resolution, normalised
/// (`"BABA IS YOU"`, `"BABA IS NOT YOU"`) and sorted.
pub fn rules(&self) -> Vec<String> {
normalised(self.level.rules())
}
// ---- Module 7 ----
/// Every distinct rule in force after resolution, normalised and sorted.
pub fn active_rules(&self) -> Vec<String> {
normalised(self.level.active_rules())
}
/// Does object `id` have the property `prop` under the rules in force?
pub fn has_property(&self, id: Id, prop: &str) -> bool {
self.level.has_property(id, prop)
}
// ---- Module 8 ----
/// Move every `YOU` object one tile in `dir`, pushing what it pushes, then
/// reparse the rules. Movement only: no conversion, hazard or win (Module 9).
pub fn move_dir(&mut self, dir: Dir) {
self.level.move_you(dir);
}
/// Where object `id` is, or `None` once it has been destroyed.
pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> {
self.level.board().find(id).map(|o| (o.x, o.y))
}
/// The kinds of the objects on tile (x, y), as placed (`"baba"`, `"text:ROCK"`), sorted.
pub fn kinds_at(&self, x: i32, y: i32) -> Vec<String> {
let mut kinds: Vec<String> = self
.level
.board()
.at(x, y)
.map(|o| o.kind.to_string())
.collect();
kinds.sort();
kinds
}
// ---- Module 9 ----
/// Play one whole turn: movement, parse, conversion, parse, hazards, win
/// (and a last parse, so the rules match the board).
pub fn step(&mut self, dir: Dir) {
self.level.step(dir);
}
/// Has a turn found a `YOU` object on a `WIN` one (or `WIN` itself)? Stays true.
pub fn won(&self) -> bool {
self.level.won()
}
/// Is no object `YOU`?
pub fn lost(&self) -> bool {
self.level.lost()
}
}
fn normalised(rules: &[puzzle::Rule]) -> Vec<String> {
let mut rules: Vec<String> = rules.iter().map(|r| r.to_string()).collect();
rules.sort();
rules.dedup();
rules
}
// ======== Part III: SpellGrid ========
/// A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards.
/// Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.rs`).
pub struct SpellGrid {
grid: Grid,
}
/// A SpellGrid level; `extra` cards are added to the course's cards, replacing any
/// with the same name.
pub fn new_spell_grid(level: LevelDef, extra: Vec<CardDef>) -> SpellGrid {
let mut library = CardLibrary::new(content::cards());
for def in extra {
library.register(def);
}
SpellGrid {
grid: Grid::new(
&level,
library,
&content::words(),
content::movement(),
content::hazards(),
content::seams(),
),
}
}
impl SpellGrid {
// ---- Part II, as on `Baba` ----
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id {
self.grid.place(kind, x, y)
}
pub fn rules(&self) -> Vec<String> {
normalised(self.grid.level().rules())
}
pub fn active_rules(&self) -> Vec<String> {
normalised(self.grid.level().active_rules())
}
pub fn has_property(&self, id: Id, prop: &str) -> bool {
self.grid.level().has_property(id, prop)
}
pub fn move_dir(&mut self, dir: Dir) {
self.grid.move_dir(dir);
}
pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> {
self.grid.level().board().find(id).map(|o| (o.x, o.y))
}
pub fn kinds_at(&self, x: i32, y: i32) -> Vec<String> {
let mut kinds: Vec<String> = self
.grid
.level()
.board()
.at(x, y)
.map(|o| o.kind.to_string())
.collect();
kinds.sort();
kinds
}
pub fn step(&mut self, dir: Dir) {
self.grid.step(dir);
}
pub fn won(&self) -> bool {
self.grid.level().won()
}
pub fn lost(&self) -> bool {
self.grid.level().lost()
}
// ---- Module 10 ----
/// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id.
pub fn cast(&mut self, card: &str, target: Option<Id>) {
self.grid.cast(card, target);
}
/// Play a minion from player 0's hand onto the empty tile (x, y); returns its id.
pub fn play(&mut self, card: &str, x: i32, y: i32) -> Id {
self.grid.play(card, x, y)
}
pub fn health(&self, id: Id) -> i32 {
self.grid.world().health(self.grid.entity(id))
}
pub fn hero(&self, player: Player) -> Id {
self.grid.id_of(self.grid.world().hero(player))
}
pub fn name_of(&self, id: Id) -> String {
self.grid
.world()
.card_name(self.grid.entity(id))
.to_string()
}
/// The minion on tile (x, y), if any.
pub fn minion_at(&self, x: i32, y: i32) -> Option<Id> {
self.grid.minion_at(x, y)
}
/// The card names in player 0's hand, in order.
pub fn hand(&self) -> Vec<String> {
let world = self.grid.world();
world
.zone(0, Zone::Hand)
.iter()
.map(|&id| world.card_name(id).to_string())
.collect()
}
/// Part I's log, with grid ids.
pub fn log(&self) -> Vec<GameEvent> {
let id = |entity: Id| self.grid.id_of(entity);
self.grid
.world()
.log()
.iter()
.filter_map(GameEvent::from_engine)
.map(|event| match event {
GameEvent::Summoned { id: e } => GameEvent::Summoned { id: id(e) },
GameEvent::Destroyed { id: e } => GameEvent::Destroyed { id: id(e) },
GameEvent::Drew { player, id: e } => GameEvent::Drew { player, id: id(e) },
GameEvent::StatsChanged {
id: e,
attack,
health,
} => GameEvent::StatsChanged {
id: id(e),
attack,
health,
},
GameEvent::Damaged { target, amount } => GameEvent::Damaged {
target: id(target),
amount,
},
GameEvent::Triggered { source, trigger } => GameEvent::Triggered {
source: id(source),
trigger,
},
other => other,
})
.collect()
}
pub fn clear_log(&mut self) {
self.grid.world_mut().clear_log();
}
/// Who makes Assemble's choices (`"base"`, `"donor"`, `"half"`).
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
self.grid.world_mut().set_chooser(Box::new(chooser));
}
/// Test hook: the naive engine reads the auras before the dead leave play, the
/// same end-of-Phase steps reversed.
pub fn with_naive_aura_timing(&mut self) {
let mut steps = END_OF_PHASE.to_vec();
steps.reverse();
self.grid.world_mut().set_end_of_phase(steps);
}
}
@@ -0,0 +1,4 @@
pub mod cli;
pub mod content;
pub mod engine;
pub mod harness;
@@ -0,0 +1,66 @@
// The SpellGrid command line, played from a script (the reference's own check; not a course test).
use std::cell::RefCell;
use std::io::{Cursor, Write};
use std::rc::Rc;
use engine::cli::{run, Outcome};
/// Collects what the CLI prints.
#[derive(Clone, Default)]
struct Screen(Rc<RefCell<Vec<u8>>>);
impl Write for Screen {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn play(level: &str, input: &[&str]) -> (Outcome, String) {
let screen = Screen::default();
let input = Box::new(Cursor::new(input.join("\n") + "\n"));
let outcome = run(level, input, Box::new(screen.clone()));
let out = String::from_utf8(screen.0.borrow().clone()).unwrap();
(outcome, out)
}
#[test]
fn cli_wins_level_1() {
let input = ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"];
let (outcome, out) = play(include_str!("../levels/l1-double-tap.json"), &input);
assert_eq!(outcome, Outcome::Won);
assert!(out.contains("DEA IS DOU"));
assert!(out.contains("You win!"));
}
#[test]
fn cli_wins_level_2_with_choices_and_play() {
let input = [
"c Graft",
"Ember Moth",
"Rune Scribe",
"bottom",
"c Ember Moth (Assembled) 4 3",
"a",
"a",
];
let (outcome, out) = play(include_str!("../levels/l2-spliced-corpse.json"), &input);
assert_eq!(outcome, Outcome::Won);
assert!(out.contains("Choose the base"));
assert!(out.contains("Hand: Ember Moth (Assembled)"));
}
#[test]
fn cli_reports_errors_and_quits() {
let input = ["c Spark", "x", "q"];
let (outcome, out) = play(
include_str!("../levels/l3-simultaneous-paradox.json"),
&input,
);
assert_eq!(outcome, Outcome::Quit);
assert!(out.contains("\"Spark\" is not in the hand"));
assert!(out.contains("Commands:"));
}
@@ -0,0 +1 @@
{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "10" }
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,20 @@
# Module 10 reference engine (TypeScript)
The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds.
- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`.
- `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the three seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id.
- Part I (`src/engine/`, outside `puzzle/`):
- `queue.ts`: `END_OF_PHASE` (`["deathCreation", "auraUpdate"]`) is the list of steps at the end of the outermost Phase, read from the world. The `spawnText` action records a `TextSpawned` event.
- `world.ts`: `setRuleAuras`/`ruleAuras` (auras that no minion carries), `setEndOfPhase`/`endOfPhase`, `entityCount`.
- `aura.ts`: an Aura Update adds the rule auras for both players; `activations` applies a `triggerCap`.
- `card.ts`: the `triggerCap` aura (not in the card format: only SpellGrid makes it).
- `effect.ts`: `spawnText` is an action, not an error. `event.ts`: `TextSpawned`.
- Part II (`src/engine/puzzle/`):
- `level.ts`: `Destroy` and `removeAll`. `step` and `moveYou` take an optional `Destroy`, which movement, conversion (`X IS NOT X`) and hazards hand their victims to. `reparse` is public.
- `grid.ts`: `Board.reserveId`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid).
- `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. `index.ts` now reads `cards/` with `node:fs`, so the command line, which runs without Vite, reads the same cards.
- `src/harness.ts`: Module 10 adds `newSpellGrid` and the `SpellGrid` interface, including the test hook `withNaiveAuraTiming()`, which reverses `END_OF_PHASE` for one game.
- `src/cli.ts` (new): the command line, `npx tsx src/cli.ts levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c <card> [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.test.ts` plays it from a script; it is the reference's own check, not a course test.
Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
{
"name": "systemic-design-workspace",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.20.5",
"tsx": "^4.23.15",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+129
View File
@@ -0,0 +1,129 @@
// SpellGrid on the command line: `npx tsx src/cli.ts levels/l1-double-tap.json`.
//
// A thin loop over the harness: it draws the level, reads a command, and calls
// `step`, `cast` or `play`. No rules live here.
//
// w a s d one turn: up, left, down, right
// c <card> [x y] cast a spell from your hand (at the minion on (x, y), if given),
// or play a minion from your hand onto the empty tile (x, y)
// q quit
//
// Assemble's choices (Graft) are asked for one at a time: answer with a number or a name.
import { readFileSync, readSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness";
const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const;
/** One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals. */
function cell(g: SpellGrid, x: number, y: number): string {
const kinds = g.kindsAt(x, y);
if (kinds.length === 0) return " . ";
// kindsAt is sorted; prefer text, which is what the player reads.
const kind = kinds.find((k) => k.startsWith("text:")) ?? kinds[0];
const name = kind.startsWith("text:") ? kind.slice(5).toUpperCase() : kind.toLowerCase();
return name.slice(0, 3).padEnd(3);
}
/** The level as text: the grid, the rules, the minions and the hand. */
export function render(g: SpellGrid, level: LevelDef): string {
const lines: string[] = [];
for (let y = 0; y < level.height; y++) {
const row: string[] = [];
for (let x = 0; x < level.width; x++) row.push(cell(g, x, y));
lines.push(row.join(" "));
}
lines.push(`Rules: ${g.activeRules().join(", ")}`);
const minions: string[] = [];
for (let y = 0; y < level.height; y++) {
for (let x = 0; x < level.width; x++) {
const id = g.minionAt(x, y);
if (id !== null) minions.push(`${g.nameOf(id)} (${x},${y}) ${g.health(id)} Health`);
}
}
lines.push(`Minions: ${minions.join("; ") || "none"}`);
lines.push(`Hand: ${g.hand().join(", ") || "empty"}`);
return lines.join("\n");
}
/** A `c` command: `c <card words...> [x y]`. */
function parseCast(args: string[]): { card: string; at?: [number, number] } {
const n = args.length;
if (n >= 3 && /^\d+$/.test(args[n - 2]) && /^\d+$/.test(args[n - 1])) {
return { card: args.slice(0, n - 2).join(" "), at: [Number(args[n - 2]), Number(args[n - 1])] };
}
return { card: args.join(" ") };
}
/**
* Play `levelText` (a level in the course's format), reading commands from
* `readLine` (undefined at the end of input) and writing to `write`. Returns
* `"won"`, `"lost"` or `"quit"`.
*/
export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string {
const level = JSON.parse(levelText) as LevelDef;
const g = newSpellGrid(level, []);
g.setChooser((kind, options) => {
write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `);
const answer = readLine()?.trim() ?? "";
return options.includes(answer) ? answer : (options[Number(answer) - 1] ?? options[0]);
});
for (;;) {
write(`${render(g, level)}\n`);
if (g.won()) {
write("You win!\n");
return "won";
}
if (g.lost()) {
write("Nothing is YOU. You lose.\n");
return "lost";
}
write("> ");
const line = readLine();
if (line === undefined) return "quit";
const [command, ...args] = line.trim().split(/\s+/);
try {
if (command === "q") return "quit";
if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]);
else if (command === "c" && args.length > 0) {
const { card, at } = parseCast(args);
const target = at === undefined ? null : g.minionAt(...at);
if (at === undefined) g.cast(card);
else if (target !== null) g.cast(card, target);
else g.play(card, ...at);
} else write("Commands: w a s d (move), c <card> [x y] (cast or play), q (quit)\n");
} catch (e) {
write(`${e instanceof Error ? e.message : String(e)}\n`);
}
}
}
/** Lines from standard input, read synchronously, so the chooser can ask mid-Sequence. */
function stdinLines(): () => string | undefined {
const buffer = Buffer.alloc(4096);
let pending = "";
let ended = false;
return () => {
while (!pending.includes("\n") && !ended) {
const n = readSync(0, buffer, 0, buffer.length, null);
if (n === 0) ended = true;
else pending += buffer.toString("utf8", 0, n);
}
if (pending === "" && ended) return undefined;
const end = pending.indexOf("\n");
const line = end < 0 ? pending : pending.slice(0, end);
pending = end < 0 ? "" : pending.slice(end + 1);
return line;
};
}
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
const path = process.argv[2];
if (path === undefined) {
process.stderr.write("usage: npx tsx src/cli.ts <level.json>\n");
process.exit(2);
}
const outcome = run(readFileSync(path, "utf8"), stdinLines(), (s) => process.stdout.write(s));
process.exit(outcome === "won" ? 0 : 1);
}
@@ -0,0 +1,48 @@
// Part II: what destroys what at the end of a turn. The engine runs these
// hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT,
// then DEFEAT), and destroys each one's victims together before the next runs.
// A hazard only reaches objects on the same float level: it asks the level what
// shares a tile (`Level.sharing`). A new hazard property is one more function here.
import type { puzzle } from "../engine";
type Level = puzzle.Level;
type ObjectId = puzzle.ObjectId;
/** The objects that have `property`, oldest first. */
const withProperty = (level: Level, property: string): ObjectId[] =>
level.board
.objects()
.map((o) => o.id)
.filter((id) => level.hasProperty(id, property));
/** A SINK object that shares its tile with anything destroys all of it, and itself. */
function sink(level: Level): ObjectId[] {
const doomed: ObjectId[] = [];
for (const id of withProperty(level, "SINK")) {
const others = level.sharing(id);
if (others.length > 0) doomed.push(id, ...others);
}
return doomed;
}
/** A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one. */
function hotMelt(level: Level): ObjectId[] {
return destroyedBy(level, "MELT", "HOT");
}
/** A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one. */
function defeat(level: Level): ObjectId[] {
return destroyedBy(level, "YOU", "DEFEAT");
}
/** The objects with `victim` that have `killer` too, or share a tile with one that does. */
function destroyedBy(level: Level, victim: string, killer: string): ObjectId[] {
const deadly = (id: ObjectId) => level.hasProperty(id, killer);
return withProperty(level, victim).filter((id) => deadly(id) || level.sharing(id).some(deadly));
}
/** The hazards, in the order the engine runs them. */
export function hazards(): puzzle.Hazard[] {
return [sink, hotMelt, defeat];
}
@@ -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 package 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.
//
// Module 10: the cards are read with Node's file system, not Vite's `import.meta.glob`,
// so the SpellGrid command line (`npx tsx src/cli.ts`), which runs without Vite, reads
// the same cards as the tests.
import { readdirSync, readFileSync } from "node:fs";
import type { CardDef, puzzle, SeamWords } from "../engine";
import seamWords from "./seams.json";
import wordList from "./words.json";
export { hazards } from "./hazards";
export { responses as movement } from "./movement";
const CARDS = new URL("../../cards/", import.meta.url);
let loaded: CardDef[] | undefined;
/** Every card in `cards/`, in file-name order. Read once. */
export function cards(): CardDef[] {
loaded ??= readdirSync(CARDS)
.filter((file) => file.endsWith(".json"))
.sort()
.map((file) => JSON.parse(readFileSync(new URL(file, CARDS), "utf8")) as CardDef);
return loaded;
}
/**
* 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.
*/
export function words(): puzzle.WordDef[] {
return wordList as puzzle.WordDef[];
}
/**
* Part III: the words that cross between the engines (`seams.json`). A noun that
* names a trigger, and a property that multiplies one: `DEATHRATTLE IS DOUBLE`.
*/
export function seams(): SeamWords {
return seamWords as SeamWords;
}
@@ -0,0 +1,45 @@
// Part II: what each property does when an object moves. The engine plans a move
// one step at a time and runs these responses for every step, in this order (the
// order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new
// movement property is one more response here.
import type { puzzle } from "../engine";
type Planner = puzzle.Planner;
type Step = puzzle.Step;
/**
* An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one)
* destroys both, and that object doesn't block the step. One partner per step:
* the oldest.
*/
function openShut(p: Planner, s: Step): boolean {
for (const [mine, theirs] of [
["OPEN", "SHUT"],
["SHUT", "OPEN"],
]) {
if (!p.has(s.mover, mine)) continue;
const partner = p.stayingAt(s.to).find((o) => p.has(o, theirs));
if (partner !== undefined) {
p.destroy(partner);
p.destroy(s.mover);
return true;
}
}
return true;
}
/** Every PUSH object on the tile ahead must step the same way, or the step fails. */
function push(p: Planner, s: Step): boolean {
return p.stayingAt(s.to).every((o) => !p.has(o, "PUSH") || p.tryMove(o));
}
/** A STOP object still on the tile ahead blocks the step. */
function stop(p: Planner, s: Step): boolean {
return !p.stayingAt(s.to).some((o) => p.has(o, "STOP"));
}
/** The movement responses, in the order the engine runs them. */
export function responses(): puzzle.Response[] {
return [openShut, push, stop];
}
@@ -0,0 +1,4 @@
{
"triggerWords": { "DEATHRATTLE": "deathrattle" },
"multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } }
}
@@ -0,0 +1,31 @@
[
{ "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": "KEG", "role": "noun" },
{ "name": "BRAMBLE", "role": "noun" },
{ "name": "HEDGE", "role": "noun" },
{ "name": "WARDEN", "role": "noun" },
{ "name": "MOTH", "role": "noun" },
{ "name": "SCRIBE", "role": "noun" },
{ "name": "KNIGHT", "role": "noun" },
{ "name": "DEATHRATTLE", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" },
{ "name": "OPEN", "role": "property" },
{ "name": "SHUT", "role": "property" },
{ "name": "HOT", "role": "property" },
{ "name": "MELT", "role": "property" },
{ "name": "SINK", "role": "property" },
{ "name": "DEFEAT", "role": "property" },
{ "name": "FLOAT", "role": "property" },
{ "name": "DOUBLE", "role": "property" }
]
@@ -0,0 +1,102 @@
// 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.slice(0, split)` (top) or `text.slice(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.
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
import type { EntityId, PlayerId, World } from "./world";
export type Half = "top" | "bottom";
const HALVES: Half[] = ["top", "bottom"];
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
export class AssembleError extends Error {
constructor(
readonly base: string,
readonly donor: string,
readonly half: Half,
) {
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
this.name = "AssembleError";
}
}
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
const canonical = (value: unknown): string =>
JSON.stringify(value, (_key, v) =>
v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
: v,
);
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
/**
* 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)".
*/
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
const topOf = (c: CardDef) => c.text.slice(0, c.split);
const bottomOf = (c: CardDef) => c.text.slice(c.split);
const top = half === "top" ? topOf(donor) : topOf(base);
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
const text = [...top, ...bottom];
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
}
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
try {
assemble(base, donor, half);
return true;
} catch (e) {
if (e instanceof AssembleError) return false;
throw e;
}
};
/**
* 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)".
*/
export function registerAssembled(world: World, def: CardDef): string {
const stem = def.name.replace(/\)$/, "");
let name = def.name;
// Same base, so the same name and stats: only the text can differ.
for (let n = 2; world.hasCard(name) && !sameText(world.card(name).def.text, def.text); n++) {
name = `${stem} ${n})`;
}
world.registerAssembled({ ...def, name });
return 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.
*/
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
const pool = world.pool();
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
if (bases.length === 0) return undefined;
const base = world.card(world.choose("base", bases)).def;
const donors = pool
.map((c) => c.def)
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
.map((d) => d.name);
if (donors.length === 0) return undefined;
const donor = world.card(world.choose("donor", donors)).def;
const half = world.choose(
"half",
HALVES.filter((h) => changes(base, donor, h)),
) as Half;
const name = registerAssembled(world, assemble(base, donor, half));
return world.create(name, player, "hand");
}
@@ -0,0 +1,55 @@
// 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.
//
// Module 10: auras can come from outside the cards. SpellGrid compiles rules on its
// grid into auras (`World.setRuleAuras`); 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.
import type { TriggerOn } from "./card";
import type { PlayerId, World } from "./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.
*/
export function auraUpdateOther(world: World): void {
const players: PlayerId[] = [0, 1];
world.setActiveAuras([
...world.minionsInPlay().flatMap((id) =>
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
),
...world.ruleAuras().flatMap((aura) => players.map((owner) => ({ owner, aura }))),
]);
}
/**
* 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 `"additive"` one adds `times - 1` extra activations. A cap
* (`triggerCap`) then limits the total to its `times`.
*/
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
let cap = Infinity;
for (const { owner: auraOwner, aura } of world.activeAuras()) {
if (auraOwner !== owner || aura.trigger !== on) continue;
if (aura.type === "triggerCap") cap = Math.min(cap, aura.times);
else if (aura.stacking === "max") max = Math.max(max, aura.times);
else extra += aura.times - 1;
}
return Math.min(cap, max + extra);
}
@@ -0,0 +1,182 @@
// 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`.
/** What a triggered ability listens for. */
export type TriggerOn =
| "selfDamaged" // this minion takes damage
| "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage
| "anyMinionDamaged" // any minion takes damage
| "heroDamaged" // either hero takes damage
| "deathrattle" // this minion dies (it resolves after the minion has left play)
| "battlecry" // this minion is played from hand
| "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires
/** The trigger's family name, as reported in `Triggered` events. */
export function triggerLabel(on: TriggerOn): string {
switch (on) {
case "selfDamaged":
case "friendlyMinionDamaged":
case "anyMinionDamaged":
case "heroDamaged":
return "damaged";
case "deathrattle":
case "battlecry":
case "endOfTurn":
return on;
}
}
/**
* 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.ts`), never mid-Phase.
*
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
*/
export type Aura =
| { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
/**
* 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`).
*/
| { type: "triggerCap"; trigger: TriggerOn; times: number };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
/** Which minions an Assemble may pick as its base. An empty filter allows any. */
export interface PoolFilter {
/** Only minions with a trigger clause listening for this. */
trigger?: TriggerOn;
}
/** One effect: a node of a card's ability, interpreted by `effect.ts`. */
export type Effect =
| { type: "dealDamage"; target: Target; amount: number }
| { type: "draw"; count: number }
| { type: "summon"; card: string; count: number }
| { type: "gainArmor"; amount: number }
| { type: "gainAttack"; target: Target; amount: number }
| { type: "destroy"; target: Target }
/** Part III (SpellGrid) only: puts a word on the grid. */
| { type: "spawnText"; word: string; at: "self" }
/** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */
| { type: "assemble"; filter: PoolFilter }
/** Give every minion in the owner's hand +attack/+health. */
| { type: "buffHand"; attack: number; health: number };
/**
* 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.
*/
export type Clause =
| { kind: "keyword"; keyword: string }
| { kind: "trigger"; on: TriggerOn }
| { kind: "aura"; modifier: Aura }
| { kind: "effect"; effect: Effect };
/** A card as data, in the course's card format. */
export interface CardDef {
name: string;
type: "minion" | "spell";
cost: number;
attack?: number;
health?: number;
noun?: string;
text: Clause[];
/** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */
split: number;
fictional?: boolean;
game?: string;
}
/** A triggered ability: "whenever <on>, <effects>". */
export interface Trigger {
on: TriggerOn;
effects: Effect[];
}
/** A registered card: its definition, and the definition's text compiled into the lists the engine reads. */
export interface Card {
def: CardDef;
/** Triggered abilities, in printed order. */
triggers: Trigger[];
/** Auras this minion has while in play. */
auras: Aura[];
/** What the card does when cast (a spell's effects). */
onCast: Effect[];
/** May an Assemble offer this card? Minions with text that were not themselves assembled. */
inPool: boolean;
}
/** Compile a definition's text. Throws if `split` is past the end of the text. */
export function compile(def: CardDef, inPool: boolean): Card {
if (def.split > def.text.length) throw new Error(`"${def.name}": split ${def.split} is past the end of its text`);
const card: Card = { def, triggers: [], auras: [], onCast: [], inPool: false };
// The trigger whose effects are still being listed, if any.
let open: Trigger | undefined;
for (const clause of def.text) {
switch (clause.kind) {
case "trigger":
open = { on: clause.on, effects: [] };
card.triggers.push(open);
break;
case "effect":
(open ? open.effects : card.onCast).push(clause.effect);
break;
case "aura":
card.auras.push(clause.modifier);
open = undefined;
break;
case "keyword": // The course has no combat, so keywords such as Taunt do nothing here.
open = undefined;
break;
}
}
card.inPool = inPool && def.type === "minion" && def.text.length > 0;
return card;
}
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
/** Every card the game can create, looked up by name. */
export class CardLibrary {
private readonly cards = new Map<string, Card>();
constructor(defs: CardDef[]) {
for (const def of defs) this.register(def);
}
/** Add a card, or replace the card with the same name. It joins the Assemble pool if it is a minion with text. */
register(def: CardDef): void {
this.cards.set(def.name, compile(def, true));
}
/** Add a card made by Assemble. It never joins the pool. */
registerAssembled(def: CardDef): void {
this.cards.set(def.name, compile(def, false));
}
has(name: string): boolean {
return this.cards.has(name);
}
get(name: string): Card {
const card = this.cards.get(name);
if (!card) throw new Error(`unknown card "${name}": add its file to cards/`);
return card;
}
/** The Assemble pool: every card with `inPool`, sorted by name. */
pool(): Card[] {
return [...this.cards.values()]
.filter((c) => c.inPool)
.sort((a, b) => (a.def.name < b.def.name ? -1 : a.def.name > b.def.name ? 1 : 0));
}
}
@@ -0,0 +1,21 @@
// 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.
import type { Event } from "./event";
import type { World } from "./world";
/**
* Remove every mortally wounded or pending-destroy minion from play, simultaneously.
* Returns their Death Events in order of play; empty if nothing died.
*/
export function deathCreationStep(world: World): Event[] {
const dying = world.minionsInPlay().filter((id) => world.isDoomed(id));
// Every position is read before anything moves: removal is simultaneous, and each
// Death Event remembers where its minion stood when the step began.
const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! }));
for (const { id, position } of deaths) world.kill(id, position);
return deaths;
}
@@ -0,0 +1,68 @@
// 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 switch can do, which is
// what makes a data card safe to load.
import type { Effect, Target } from "./card";
import type { Action, Ctx } from "./queue";
import type { EntityId } from "./world";
const times = <T>(count: number, make: () => T): T[] => Array.from({ length: count }, make);
/** The actions one effect performs, in order, given where and for whom it runs. */
export function actions(effect: Effect, ctx: Ctx): Action[] {
const player = ctx.owner;
switch (effect.type) {
case "dealDamage":
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
case "draw":
return times(effect.count, () => ({ type: "draw", player }));
case "summon":
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
case "gainArmor":
return [{ type: "gainArmor", player, amount: effect.amount }];
case "gainAttack":
return targets(effect.target, ctx).map((id) => ({ type: "gainAttack", id, amount: effect.amount }));
case "destroy":
return [{ type: "destroy", targets: targets(effect.target, ctx) }];
case "assemble":
return [{ type: "assemble", player, filter: effect.filter }];
case "buffHand":
return ctx.world
.minionsInHand(player)
.map((id) => ({ type: "buff", id, attack: effect.attack, health: effect.health }));
case "spawnText":
return [{ type: "spawnText", source: ctx.minion(), word: effect.word }];
}
}
/** The entities a target names, from where the effect runs. */
function targets(target: Target, ctx: Ctx): EntityId[] {
const { world } = ctx;
const others = (ids: EntityId[]) => ids.filter((id) => id !== ctx.source);
switch (target) {
case "self":
return [ctx.minion()];
case "allMinions":
return world.minionsInPlay();
case "allOtherMinions":
return others(world.minionsInPlay());
case "allOtherCharacters":
return others([world.hero(0), world.hero(1), ...world.minionsInPlay()]);
case "enemyHero":
return [ctx.enemyHero()];
case "chosen":
return ctx.target === undefined ? [] : [ctx.target];
}
}
/**
* 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.
*/
function summonPosition(ctx: Ctx): number | undefined {
const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source);
return index === undefined ? ctx.position : index + 1;
}
@@ -0,0 +1,27 @@
// What happened, in order. The engine records an event each time it changes the game.
import type { TriggerOn } from "./card";
import type { EntityId, PlayerId } from "./world";
export type Event =
| { type: "Summoned"; id: EntityId }
| { type: "Damaged"; target: EntityId; amount: number }
| { type: "Triggered"; source: EntityId; on: TriggerOn }
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "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.
*/
| { type: "Destroyed"; id: EntityId; position: number }
| { type: "DeathPhase"; round: number }
/** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */
| { type: "Played"; id: EntityId }
/**
* 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.
*/
| { type: "TextSpawned"; source: EntityId; word: string };
@@ -0,0 +1,53 @@
// 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.
// 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.
//
// Part III lives in `spellgrid.ts`, the only file that knows both engines.
// Module 10: SpellGrid. Part I gains auras from outside the cards (`World.setRuleAuras`),
// 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` hook.
export * as puzzle from "./puzzle";
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
export {
CardLibrary,
triggerLabel,
type Aura,
type Card,
type CardDef,
type Clause,
type Effect,
type PoolFilter,
type Target,
type Trigger,
type TriggerOn,
} from "./card";
export type { Event } from "./event";
export { auraUpdateOther } from "./aura";
export { Ctx, END_OF_PHASE, runSequence, summon, type Action, type EndOfPhaseStep } from "./queue";
export { ruleAuras, SpellGrid, type SeamWords } from "./spellgrid";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
@@ -0,0 +1,127 @@
// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
// objects may share a tile.
export type ObjectId = number;
/** A level in the course's level format (`course/schema/level.schema.json`). */
export interface LevelDef {
width: number;
height: number;
objects: LevelObject[];
/** Module 10, read only by SpellGrid: minions in play, each on its tile. */
cards?: LevelCard[];
/** Module 10, read only by SpellGrid: the cards in player 0's hand. */
hand?: string[];
}
/** A minion card on the grid (Module 10). */
export interface LevelCard {
card: string;
x: number;
y: number;
}
export interface LevelObject {
kind: string;
x: number;
y: number;
}
/**
* 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>"`).
*/
export type Kind = { type: "thing"; noun: string } | { type: "text"; word: string };
const TEXT_PREFIX = "text:";
export const Kind = {
parse(kind: string): Kind {
return kind.startsWith(TEXT_PREFIX)
? { type: "text", word: kind.slice(TEXT_PREFIX.length).toUpperCase() }
: { type: "thing", noun: kind.toUpperCase() };
},
/** The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`. */
format(kind: Kind): string {
return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase();
},
};
export interface Obj {
id: ObjectId;
kind: Kind;
x: number;
y: number;
}
export class Board {
private readonly objectsById = new Map<ObjectId, Obj>();
private nextId = 0;
constructor(
readonly width: number,
readonly height: number,
) {}
inBounds(x: number, y: number): boolean {
return x >= 0 && x < this.width && y >= 0 && y < this.height;
}
/** Add an object; ids start at 1 and are never reused. Throws off the board. */
place(kind: Kind, x: number, y: number): ObjectId {
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
const id = ++this.nextId;
this.objectsById.set(id, { id, kind, x, y });
return 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.
*/
reserveId(): ObjectId {
return ++this.nextId;
}
/** The object `id`. Throws if there is none. */
get(id: ObjectId): Obj {
const o = this.objectsById.get(id);
if (o === undefined) throw new Error(`no object ${id}`);
return o;
}
/** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */
find(id: ObjectId): Obj | undefined {
if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`);
return this.objectsById.get(id);
}
/** Take object `id` off the board for good. */
remove(id: ObjectId): void {
this.objectsById.delete(id);
}
/** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */
shift(id: ObjectId, [dx, dy]: readonly [number, number]): void {
const o = this.get(id);
const [x, y] = [o.x + dx, o.y + dy];
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
o.x = x;
o.y = y;
}
/** Make object `id` a `kind`, where it stands. Throws if there is no such object. */
setKind(id: ObjectId, kind: Kind): void {
this.get(id).kind = kind;
}
/** The objects on tile (x, y), oldest first. */
at(x: number, y: number): Obj[] {
return this.objects().filter((o) => o.x === x && o.y === y);
}
/** Every object, oldest first. */
objects(): Obj[] {
return [...this.objectsById.values()];
}
}
@@ -0,0 +1,31 @@
// 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.hasProperty`, which knows which nouns name them.
//
// Module 8: movement. `Level.moveYou` 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` and `Level.moveYou` take a `Destroy`, which decides what
// happens to the objects they destroy; `Board.reserveId` hands out an id no object has.
// The puzzle engine still knows nothing of cards: SpellGrid (`../spellgrid.ts`) uses these.
export { Board, Kind, type LevelCard, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
export { FLOAT, Level, removeAll, WIN, YOU, type Destroy } from "./level";
export { delta, Planner, type Dir, type MoveContext, type Plan, type Pos, type Response, type Step } from "./movement";
export { BASE_RULES, isConversion, isIdentity, negates, parse, ruleText, type Rule } from "./parse";
export { resolve } from "./resolve";
export { TURN, type Hazard, type Phase } from "./turn";
export { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word";
@@ -0,0 +1,248 @@
// 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 `hasProperty`.
//
// 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.
//
// Module 10: what happens to the objects a turn destroys is a `Destroy` the caller
// may pass in. The default removes them. SpellGrid passes one that sends minions to
// Part I's Death Creation Step instead.
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
import { delta, Planner, type Dir, type Plan, type Response } from "./movement";
import { isConversion, isIdentity, parse, type Rule } from "./parse";
import { resolve } from "./resolve";
import { TURN, type Hazard } from "./turn";
import { Lexicon, type WordDef } from "./word";
/** The property that makes an object move when the player moves. */
export const YOU = "YOU";
/** The property that wins the level when a `YOU` object is on it, or is it. */
export const WIN = "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.
*/
export const FLOAT = "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.
*/
export type Destroy = (level: Level, ids: ObjectId[]) => void;
/** The default `Destroy`: take every object off the board. */
export const removeAll: Destroy = (level, ids) => {
for (const id of ids) level.board.remove(id);
};
export class Level {
readonly board: Board;
private readonly lexicon: Lexicon;
private compiled: Rule[] = [];
private active: Rule[] = [];
private hasWon = false;
/**
* A level from its definition, the words content provides, and content's
* movement responses and hazards, each in the order they are to run.
*/
constructor(
def: LevelDef,
words: readonly WordDef[],
private readonly moveResponses: readonly Response[],
private readonly hazards: readonly Hazard[],
) {
this.board = new Board(def.width, def.height);
this.lexicon = new Lexicon(words);
for (const o of def.objects) this.board.place(Kind.parse(o.kind), o.x, o.y);
this.reparse();
}
place(kind: string, x: number, y: number): ObjectId {
const id = this.board.place(Kind.parse(kind), x, y);
this.reparse();
return id;
}
/** The movement responses, in the order they run. */
responses(): readonly Response[] {
return this.moveResponses;
}
/** Every rule the text spells, as parsed: negated rules and duplicates included. */
rules(): readonly Rule[] {
return this.compiled;
}
/** The rules in force after the resolution pass. */
activeRules(): readonly Rule[] {
return this.active;
}
/**
* Does object `id` have `property`? Yes if an active rule gives it to a noun
* that names the object and no active negated rule takes it from one. A
* destroyed object has no properties.
*/
hasProperty(id: ObjectId, property: string): boolean {
const object = this.board.find(id);
if (object === undefined) return false;
const kind = object.kind;
const aboutIt = this.active.filter((r) => r.complement === property && this.lexicon.names(r.subject, kind));
return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated);
}
/**
* 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.
*/
sharing(id: ObjectId): ObjectId[] {
const o = this.board.get(id);
const floats = this.hasProperty(id, FLOAT);
return this.board
.at(o.x, o.y)
.filter((other) => other.id !== id && this.hasProperty(other.id, FLOAT) === floats)
.map((other) => other.id);
}
/** Has a turn's win check found a `YOU` object on a `WIN` one? Stays true. */
won(): boolean {
return this.hasWon;
}
/** Is there no `YOU` object left? */
lost(): boolean {
return !this.board.objects().some((o) => this.hasProperty(o.id, YOU));
}
/**
* Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
* only: no conversion, hazard or win.
*/
moveYou(dir: Dir, destroy: Destroy = removeAll): void {
this.movement(dir, destroy);
this.reparse();
}
/** Play one turn: the phases of `TURN`, in order. Destroyed objects go to `destroy`. */
step(dir: Dir, destroy: Destroy = removeAll): void {
for (const phase of TURN) {
switch (phase) {
case "movement":
this.movement(dir, destroy);
break;
case "parse":
this.reparse();
break;
case "conversion":
this.convert(destroy);
break;
case "hazards":
this.runHazards(destroy);
break;
case "win":
this.checkWin();
break;
}
}
}
/**
* 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.
*/
private movement(dir: Dir, destroy: Destroy): void {
const [dx, dy] = delta(dir);
const ahead = (o: { x: number; y: number }) => o.x * dx + o.y * dy;
const you = this.board
.objects()
.filter((o) => this.hasProperty(o.id, YOU))
.sort((a, b) => ahead(b) - ahead(a) || a.id - b.id)
.map((o) => o.id);
for (const id of you) {
if (this.board.find(id) === undefined) continue; // destroyed by an earlier move
const planner = new Planner(this, dir);
if (planner.tryMove(id)) this.commit(planner.result(), dir, destroy);
}
}
/** Destroy, then move what is still on the board (a `Destroy` may remove more than it was given). */
private commit(plan: Plan, dir: Dir, destroy: Destroy): void {
if (plan.destroyed.size > 0) destroy(this, [...plan.destroyed]);
for (const id of plan.moving) {
if (!plan.destroyed.has(id) && this.board.find(id) !== undefined) this.board.shift(id, delta(dir));
}
}
/**
* 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`.
*
* 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.
*/
private convert(destroy: Destroy): void {
const changes: { id: ObjectId; x: number; y: number; deleted: boolean; into: Kind[] }[] = [];
for (const o of this.board.objects()) {
const aboutIt = this.active.filter((r) => this.lexicon.names(r.subject, o.kind));
const deleted = aboutIt.some((r) => r.negated && isIdentity(r));
const locked = aboutIt.some((r) => !r.negated && isIdentity(r));
const into: Kind[] = [];
if (!locked) {
for (const r of aboutIt) {
if (r.negated || !isConversion(r)) continue;
const kind = this.lexicon.thingNamed(r.complement);
if (kind === undefined) continue;
const same = (k: Kind) => Kind.format(k) === Kind.format(kind);
if (!same(o.kind) && !into.some(same)) into.push(kind);
}
}
if (deleted || into.length > 0) changes.push({ id: o.id, x: o.x, y: o.y, deleted, into });
}
for (const { id, x, y, deleted, into } of changes) {
if (!deleted) this.board.setKind(id, into[0]);
for (const kind of deleted ? into : into.slice(1)) this.board.place(kind, x, y);
}
const deleted = changes.filter((c) => c.deleted).map((c) => c.id);
if (deleted.length > 0) destroy(this, deleted);
}
/**
* 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.
*/
private runHazards(destroy: Destroy): void {
for (const hazard of this.hazards) {
const victims = hazard(this);
if (victims.length > 0) destroy(this, victims);
}
}
/**
* The level is won if any `YOU` object is `WIN`, or shares its tile with a
* `WIN` object on its float level.
*/
private checkWin(): void {
const wins = (id: ObjectId) => this.hasProperty(id, WIN);
const won = this.board
.objects()
.filter((o) => this.hasProperty(o.id, YOU))
.some((o) => wins(o.id) || this.sharing(o.id).some(wins));
this.hasWon ||= won;
}
/** Recompile the rules from the board: parse, then resolve. */
reparse(): void {
this.compiled = parse(this.board, this.lexicon);
this.active = resolve(this.compiled);
}
}
@@ -0,0 +1,121 @@
// 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.
import type { Board, ObjectId } from "./grid";
/** One of the four directions a move can take. */
export type Dir = "up" | "down" | "left" | "right";
export type Pos = readonly [number, number];
/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */
export function delta(dir: Dir): Pos {
switch (dir) {
case "up":
return [0, -1];
case "down":
return [0, 1];
case "left":
return [-1, 0];
case "right":
return [1, 0];
}
}
/** One object stepping one tile, from `from` to `to`, in direction `dir`. */
export interface Step {
mover: ObjectId;
from: Pos;
to: Pos;
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.
*/
export type Response = (p: Planner, s: Step) => boolean;
/**
* 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.
*/
export interface Plan {
moving: Set<ObjectId>;
destroyed: Set<ObjectId>;
}
/** What the planner reads: the board, the properties in force, the responses. */
export interface MoveContext {
readonly board: Board;
hasProperty(id: ObjectId, property: string): boolean;
responses(): readonly Response[];
}
/** Plans one move on a board it only reads. */
export class Planner {
private plan: Plan = { moving: new Set(), destroyed: new Set() };
constructor(
private readonly level: MoveContext,
private readonly dir: Dir,
) {}
/** Does object `id` have `property`, under the rules in force before the move? */
has(id: ObjectId, property: string): boolean {
return this.level.hasProperty(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.
*/
stayingAt([x, y]: Pos): ObjectId[] {
return this.level.board
.at(x, y)
.map((o) => o.id)
.filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id));
}
/** Plan object `id`'s destruction. */
destroy(id: ObjectId): void {
this.plan.destroyed.add(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.
*/
tryMove(id: ObjectId): boolean {
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
const o = this.level.board.get(id);
const [dx, dy] = delta(this.dir);
const to: Pos = [o.x + dx, o.y + dy];
// The edge of the level blocks every step (the course's choice; see the lesson).
if (!this.level.board.inBounds(...to)) return false;
const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) };
this.plan.moving.add(id);
const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir };
const allowed = this.level.responses().every((respond) => respond(this, step));
if (!allowed) this.plan = before;
return allowed;
}
result(): Plan {
return this.plan;
}
}
@@ -0,0 +1,130 @@
// 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.
//
// The parser only reads; which rules win is the resolution pass (`resolve`).
import type { Board, ObjectId } from "./grid";
import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word";
/**
* One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`.
* `text` is the text objects that spell it (subject, `IS`, `NOT` if any,
* complement), or empty for a base rule.
*/
export interface Rule {
subject: string;
complement: string;
complementRole: Role;
negated: boolean;
text: ObjectId[];
}
/** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`. */
export const ruleText = (r: Rule): string => `${r.subject} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`;
/** `X IS Y` where `Y` is a noun: every X is to become a Y. */
export const isConversion = (r: Rule): boolean => r.complementRole === "noun";
/** `X IS X` (or `X IS NOT X`). */
export const isIdentity = (r: Rule): boolean => r.subject === r.complement;
/** Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. */
export const negates = (n: Rule, r: Rule): boolean =>
n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement;
/** Rules in force with no text: the game's base rules (`[subject, property]`). */
export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]];
type Tiles = Map<string, Token[]>;
type Pos = readonly [number, number];
/** A word in a run, with the `NOT` in front of it if there is one. */
interface Word {
not?: Token;
token: Token;
}
/**
* Every rule the board spells, base rules first, negated rules included. A rule
* spelled by two different sets of text objects appears twice.
*/
export function parse(board: Board, lexicon: Lexicon): Rule[] {
const tiles = lex(board, lexicon);
const rules: Rule[] = BASE_RULES.map(([subject, property]) => ({
subject,
complement: property,
complementRole: "property",
negated: false,
text: [],
}));
for (const o of board.objects()) {
if (o.kind.type !== "text" || o.kind.word !== IS) continue;
for (const [dx, dy] of [
[1, 0],
[0, 1],
] as const) {
const ss = subjects(tiles, [o.x - dx, o.y - dy], [-dx, -dy]);
const cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]);
for (const s of ss) {
for (const c of cs) {
rules.push({
subject: s.word,
complement: c.token.word,
complementRole: c.token.role,
negated: c.not !== undefined,
text: c.not ? [s.id, o.id, c.not.id, c.token.id] : [s.id, o.id, c.token.id],
});
}
}
}
}
return rules;
}
const wordsAt = (tiles: Tiles, [x, y]: Pos): Token[] => tiles.get(tileKey(x, y)) ?? [];
const holds = (tiles: Tiles, p: Pos, word: string): boolean => wordsAt(tiles, p).some((t) => t.word === word);
const ahead = ([x, y]: Pos, [dx, dy]: Pos): Pos => [x + dx, y + dy];
/**
* 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).
*/
function subjects(tiles: Tiles, at: Pos, step: Pos): Token[] {
const before = ahead(at, step);
if (holds(tiles, before, NOT)) return [];
const found = wordsAt(tiles, at).filter((t) => t.role === "noun");
if (found.length > 0 && holds(tiles, before, AND)) found.push(...subjects(tiles, ahead(before, step), step));
return found;
}
/**
* 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.
*/
function complements(tiles: Tiles, at: Pos, step: Pos): Word[] {
const fits = (t: Token) => t.role === "noun" || t.role === "property";
const found: Word[] = [];
// Plain words here, and negated words one tile on: a stack may hold both.
const branches: [Token | undefined, Pos][] = [[undefined, at]];
for (const not of wordsAt(tiles, at).filter((t) => t.word === NOT)) branches.push([not, ahead(at, step)]);
for (const [not, p] of branches) {
const words = wordsAt(tiles, p)
.filter(fits)
.map((token) => ({ not, token }));
if (words.length === 0) continue;
found.push(...words);
const andAt = ahead(p, step);
if (holds(tiles, andAt, AND)) found.push(...complements(tiles, ahead(andAt, step), step));
}
return found;
}
@@ -0,0 +1,23 @@
// 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`.
import { isConversion, isIdentity, negates, type Rule } from "./parse";
/** The rules in force, in the order they were parsed. */
export function resolve(rules: readonly Rule[]): Rule[] {
const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r)));
const locked = (subject: string) =>
survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject);
return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject));
}
@@ -0,0 +1,33 @@
// 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.
import type { ObjectId } from "./grid";
import type { Level } from "./level";
/**
* One phase of the turn:
* - `"movement"`: every `YOU` object moves (Module 8), without the reparse;
* - `"parse"`: the rules are recompiled from the board;
* - `"conversion"`: `X IS Y` conversions, and `X IS NOT X` deletions;
* - `"hazards"`: content's hazards, in content's order;
* - `"win"`: a `YOU` object on a `WIN` one (or `WIN` itself) wins the level.
*/
export type Phase = "movement" | "parse" | "conversion" | "hazards" | "win";
/** The turn, in order. */
export const TURN: readonly Phase[] = ["movement", "parse", "conversion", "parse", "hazards", "win", "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.
*/
export type Hazard = (level: Level) => ObjectId[];
@@ -0,0 +1,106 @@
// 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.
import type { Board, Kind, ObjectId } from "./grid";
export type Role = "noun" | "property" | "operator";
/**
* Which objects a noun names when it is the subject of a rule:
* - `"named"` (the default): the things of its own name, `<NOUN>` names every object of kind `"<noun>"`;
* - `"text"`: every text object (the meta-noun `TEXT`);
* - `"things"`: every object that is not text, whatever its noun.
*/
export type Selects = "named" | "text" | "things";
/**
* One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`,
* optionally with `"selects"` (default `"named"`).
*/
export interface WordDef {
name: string;
role: Role;
selects?: Selects;
}
export const IS = "IS";
export const AND = "AND";
export const NOT = "NOT";
/** The meta-noun for every text object. */
export const TEXT = "TEXT";
/** Every word the parser can read, with its role. */
export class Lexicon {
private readonly roles = new Map<string, Role>();
private readonly selects = new Map<string, Selects>();
/** The engine's own words plus `words`. Content can't redefine an engine word. */
constructor(words: readonly WordDef[]) {
for (const w of words) {
this.roles.set(w.name.toUpperCase(), w.role);
this.selects.set(w.name.toUpperCase(), w.selects ?? "named");
}
for (const op of [IS, AND, NOT]) this.roles.set(op, "operator");
this.roles.set(TEXT, "noun");
this.selects.set(TEXT, "text");
}
/** `undefined` for a word the lexicon doesn't know: such text spells nothing. */
role(word: string): Role | undefined {
return this.roles.get(word);
}
/** Does the noun `word`, as the subject of a rule, name an object of this kind? */
names(word: string, kind: Kind): boolean {
switch (this.selects.get(word) ?? "named") {
case "named":
return kind.type === "thing" && kind.noun === word;
case "text":
return kind.type === "text";
case "things":
return kind.type === "thing";
}
}
/**
* What a conversion into the noun `word` makes (Module 9): a thing of that
* name, if `word` names the things of its own name. `undefined` for a noun that
* names a group (`TEXT`, or `ALL` if content adds it): the course doesn't
* convert into those.
*/
thingNamed(word: string): Kind | undefined {
return (this.selects.get(word) ?? "named") === "named" ? { type: "thing", noun: word } : undefined;
}
}
/** A piece of text the parser can read: which object, which word, which role. */
export interface Token {
id: ObjectId;
word: string;
role: Role;
}
/** Tile id for maps: `"x,y"`. */
export const tileKey = (x: number, y: number): string => `${x},${y}`;
/**
* 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.
*/
export function lex(board: Board, lexicon: Lexicon): Map<string, Token[]> {
const tiles = new Map<string, Token[]>();
for (const o of board.objects()) {
if (o.kind.type !== "text") continue;
const role = lexicon.role(o.kind.word);
if (role === undefined) continue;
const tile = tileKey(o.x, o.y);
const tokens = tiles.get(tile) ?? [];
tokens.push({ id: o.id, word: o.kind.word, role });
tiles.set(tile, tokens);
}
return tiles;
}
@@ -0,0 +1,258 @@
// 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.ts`, 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.ts`) 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 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.
import { assembleFromPool } from "./assemble";
import { activations, auraUpdateOther } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
import type { Event } from "./event";
import type { EntityId, PlayerId, World } from "./world";
/** Something the engine can do. Card effects return these instead of changing the world. */
export type Action =
/** Deal `amount` to each target at once: all damage first, then the reactions. */
| { type: "damage"; targets: EntityId[]; amount: number }
| { type: "draw"; player: PlayerId }
| { type: "gainArmor"; player: PlayerId; amount: number }
| { type: "gainAttack"; id: EntityId; amount: number }
| { type: "cast"; card: string; player: PlayerId; target?: EntityId }
/** Mark each target pending destroy. They leave play in the next Death Creation Step. */
| { type: "destroy"; targets: EntityId[] }
/** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */
| { type: "summon"; card: string; player: PlayerId; at?: number }
/** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */
| { type: "play"; id: EntityId }
/** Put an Assembled minion into `player`'s hand (see `assemble.ts`). */
| { type: "assemble"; player: PlayerId; filter: PoolFilter }
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number }
/** Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it. */
| { type: "spawnText"; source: EntityId; word: string };
/**
* A step that runs when the outermost Phase ends:
* - `"deathCreation"`: the Death Creation Step (Module 3);
* - `"auraUpdate"`: an Aura Update (Other) (Module 4).
*/
export type EndOfPhaseStep = "deathCreation" | "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.
*/
export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"];
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
readonly world: World,
/** The player the effect works for. */
readonly owner: PlayerId,
/** The minion whose trigger is resolving; undefined for a spell. */
readonly source?: EntityId,
/** The chosen target, if any. */
readonly target?: EntityId,
/**
* The source's board position. For a Deathrattle, the source has left play,
* and this is the position its Death Event remembered.
*/
readonly position?: number,
) {}
/** The minion whose trigger is resolving. */
minion(): EntityId {
if (this.source === undefined) throw new Error("only a triggered ability has a source");
return this.source;
}
enemyHero(): EntityId {
return this.world.hero(this.owner === 0 ? 1 : 0);
}
}
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
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 deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(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.
*/
function endOfOutermostPhase(world: World): Event[] {
let deaths: Event[] = [];
for (const step of world.endOfPhase()) {
switch (step) {
case "deathCreation":
deaths = deathCreationStep(world);
break;
case "auraUpdate":
auraUpdateOther(world);
break;
}
}
return deaths;
}
/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */
export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId {
const id = world.create(card, player, "board", at);
auraUpdateOther(world);
return id;
}
function perform(world: World, action: Action): void {
switch (action.type) {
case "damage": {
// Area damage: every Damage Event is created, in order of play, before any reaction.
const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
const events: Event[] = [];
for (const target of targets) {
world.damage(target, action.amount);
events.push({ type: "Damaged", target, amount: action.amount });
}
react(world, events);
break;
}
case "draw":
world.draw(action.player);
break;
case "gainArmor":
world.gainArmor(action.player, action.amount);
break;
case "gainAttack":
world.gainAttack(action.id, action.amount);
break;
case "destroy":
for (const target of action.targets) world.markDestroyed(target);
break;
case "summon":
summon(world, action.card, action.player, action.at);
break;
case "play": {
if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.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.moveTo(action.id, "board");
const played: Event = { type: "Played", id: action.id };
world.record(played);
react(world, [played]);
break;
}
case "cast": {
const spell = world.card(action.card);
if (spell.def.type !== "spell") throw new Error(`"${action.card}" is not a spell`);
world.record({ type: "SpellCast", card: action.card });
runEffects(world, spell.onCast, new Ctx(world, action.player, undefined, action.target));
break;
}
case "assemble":
assembleFromPool(world, action.player, action.filter);
break;
case "buff":
world.buff(action.id, action.attack, action.health);
break;
case "spawnText":
world.record({ type: "TextSpawned", source: action.source, word: action.word });
break;
}
}
/**
* 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.
*/
function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
for (const effect of effects) {
for (const action of actions(effect, ctx)) perform(world, action);
}
}
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
const queue: [EntityId, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
for (let i = 0; i < times; i++) {
world.record({ type: "Triggered", source, on: trigger.on });
const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source);
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, 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.
*/
function listeners(world: World, event: Event): EntityId[] {
const inPlay = world.minionsInPlay();
if (event.type !== "Destroyed") return inPlay;
return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
}
/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */
function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean {
if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener;
if (event.type === "Played") return on === "battlecry" && event.id === listener;
if (event.type !== "Damaged") return false;
const isMinion = !world.isHero(event.target);
switch (on) {
case "selfDamaged":
return event.target === listener;
case "friendlyMinionDamaged":
return isMinion && world.ownerOf(event.target) === world.ownerOf(listener);
case "anyMinionDamaged":
return isMinion;
case "heroDamaged":
return !isMinion;
case "deathrattle":
case "battlecry":
case "endOfTurn":
return false;
}
}
@@ -0,0 +1,229 @@
// Part III: SpellGrid, the two engines in one level.
//
// Part I (the card engine) and Part II (the grid engine, `puzzle/`) don't import each
// other. This file is the only place that knows both, and they meet at three 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.setRuleAuras`. 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 `destroy` below. 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.
//
// 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.
import { auraUpdateOther } from "./aura";
import type { Aura, CardLibrary, TriggerOn } from "./card";
import type { Event } from "./event";
import * as puzzle from "./puzzle";
import { runSequence, type Action } from "./queue";
import { World, type EntityId } from "./world";
/**
* The words that cross the seam, from content: nouns that name a trigger
* (`"DEATHRATTLE": "deathrattle"`) and properties that multiply one
* (`"DOUBLE": { "times": 2, "stacking": "max" }`).
*/
export interface SeamWords {
triggerWords: Record<string, TriggerOn>;
multiplierWords: Record<string, { times: number; stacking: "max" | "additive" }>;
}
/**
* 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`.
*/
export function ruleAuras(rules: readonly puzzle.Rule[], words: SeamWords): Aura[] {
const auras: Aura[] = [];
for (const rule of rules) {
const trigger = words.triggerWords[rule.subject];
const multiplier = words.multiplierWords[rule.complement];
if (trigger === undefined || multiplier === undefined) continue;
auras.push(
rule.negated
? { type: "triggerCap", trigger, times: 1 }
: { type: "triggerMultiplier", trigger, ...multiplier },
);
}
return auras;
}
/** A SpellGrid level in play: a grid and a game, kept in step. Ids are grid ids (see above). */
export class SpellGrid {
readonly world: World;
readonly level: puzzle.Level;
/** Every entity's id. */
private readonly ids = new Map<EntityId, puzzle.ObjectId>();
/** The entity behind each id (a card's old id still finds it after it is played). */
private readonly entities = new Map<puzzle.ObjectId, EntityId>();
/**
* 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.
*/
constructor(
def: puzzle.LevelDef,
library: CardLibrary,
words: readonly puzzle.WordDef[],
responses: readonly puzzle.Response[],
hazards: readonly puzzle.Hazard[],
private readonly seamWords: SeamWords,
) {
this.world = new World(library);
this.level = new puzzle.Level(def, words, responses, hazards);
for (const { card, x, y } of def.cards ?? []) {
const id = this.level.board.place(this.nounOf(card), x, y);
this.link(this.world.create(card, 0, "board"), id);
}
// Every minion is in play: one Aura Update, as for a summon.
auraUpdateOther(this.world);
for (const card of def.hand ?? []) this.world.create(card, 0, "hand");
this.linkTheRest();
this.gridChanged();
}
// ---- Ids ----
/** The id of entity `entity`. */
idOf(entity: EntityId): puzzle.ObjectId {
const id = this.ids.get(entity);
if (id === undefined) throw new Error(`entity ${entity} has no id`);
return id;
}
/** The entity behind `id`. Throws if `id` is not a card's. */
entity(id: puzzle.ObjectId): EntityId {
const entity = this.entities.get(id);
if (entity === undefined) throw new Error(`${id} is not a card`);
return entity;
}
/** The id of the minion on tile (x, y), if there is one. */
minionAt(x: number, y: number): puzzle.ObjectId | undefined {
return this.level.board.at(x, y).find((o) => this.entities.has(o.id))?.id;
}
private link(entity: EntityId, id: puzzle.ObjectId): void {
this.ids.set(entity, id);
this.entities.set(id, entity);
}
/** Give every entity that has no id one that no object will have. */
private linkTheRest(): void {
for (let entity = 0; entity < this.world.entityCount(); entity++) {
if (!this.ids.has(entity)) this.link(entity, this.level.board.reserveId());
}
}
private nounOf(card: string): puzzle.Kind {
const { def } = this.world.card(card);
if (def.type !== "minion" || def.noun === undefined) throw new Error(`"${card}" is not a minion with a noun`);
return puzzle.Kind.parse(def.noun.toLowerCase());
}
// ---- What the player does ----
/** Add an object or a piece of text, then reparse. */
place(kind: string, x: number, y: number): puzzle.ObjectId {
const id = this.level.place(kind, x, y);
this.gridChanged();
return id;
}
/** One whole turn (Module 9); what it destroys goes through seam 2. */
step(dir: puzzle.Dir): void {
this.level.step(dir, this.destroy);
this.gridChanged();
}
/** Movement only (Module 8), then a reparse. */
move(dir: puzzle.Dir): void {
this.level.moveYou(dir, this.destroy);
this.gridChanged();
}
/** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. */
cast(card: string, target?: puzzle.ObjectId): void {
const inHand = this.fromHand(card, "spell");
this.world.moveTo(inHand, "graveyard");
const chosen = target === undefined ? undefined : this.entity(target);
this.sequence({ type: "cast", card, player: 0, target: chosen });
}
/** Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence. Returns its new id. */
play(card: string, x: number, y: number): puzzle.ObjectId {
const inHand = this.fromHand(card, "minion");
if (this.level.board.at(x, y).length > 0) throw new Error(`(${x}, ${y}) is not empty`);
const id = this.level.board.place(this.nounOf(card), x, y);
this.link(inHand, id);
this.sequence({ type: "play", id: inHand });
return id;
}
private fromHand(card: string, type: "minion" | "spell"): EntityId {
const inHand = this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card);
if (inHand === undefined) throw new Error(`"${card}" is not in the hand`);
if (this.world.card(card).def.type !== type) throw new Error(`"${card}" is not a ${type}`);
return inHand;
}
// ---- The seams ----
/**
* 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.
*/
private readonly destroy: puzzle.Destroy = (level, ids) => {
const present = [...new Set(ids)].filter((id) => level.board.find(id) !== undefined);
const minions = present.filter((id) => this.entities.has(id));
for (const id of present) if (!this.entities.has(id)) level.board.remove(id);
if (minions.length === 0) return;
this.syncRules();
this.sequence({ type: "destroy", targets: minions.map((id) => this.entity(id)) });
};
/** Run one Sequence in Part I, then seam 3: apply its grid changes and reparse. */
private sequence(action: Action): void {
const from = this.world.log().length;
runSequence(this.world, action);
const events = this.world.log().slice(from);
const board = this.level.board;
// Text first, while every minion that wrote it is still on its tile.
for (const event of events) {
if (event.type !== "TextSpawned") continue;
// A minion with no id (summoned by this Sequence) is not on the grid: nowhere to write.
const id = this.ids.get(event.source);
const source = id === undefined ? undefined : board.find(id);
if (source !== undefined) board.place({ type: "text", word: event.word }, source.x, source.y);
}
for (const event of deaths(events)) {
const id = this.ids.get(event.id);
if (id !== undefined) board.remove(id);
}
this.linkTheRest();
this.gridChanged();
}
/** The grid changed: reparse it, then seam 1. */
private gridChanged(): void {
this.level.reparse();
this.syncRules();
}
/** Seam 1: compile the rules in force into auras for Part I, then run an Aura Update. */
private syncRules(): void {
this.world.setRuleAuras(ruleAuras(this.level.activeRules(), this.seamWords));
auraUpdateOther(this.world);
}
}
const deaths = (events: readonly Event[]) =>
events.filter((e): e is Extract<Event, { type: "Destroyed" }> => e.type === "Destroyed");
@@ -0,0 +1,334 @@
// Entities, zones, order of play, stats and the event log: the game's state.
// The rules for resolving actions live in `queue.ts`.
import type { Aura, Card, CardDef, CardLibrary } from "./card";
import type { Event } from "./event";
import { END_OF_PHASE, type EndOfPhaseStep } from "./queue";
/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */
export type EntityId = number;
export type PlayerId = 0 | 1;
export type Zone = "deck" | "hand" | "board" | "graveyard";
/** Starting Health of each hero. */
export const HERO_HEALTH = 30;
interface Entity {
card: string;
owner: PlayerId;
zone: Zone;
/** Stamped each time the entity enters the board; undefined until it first does. */
orderOfPlay?: number;
attack: number;
/** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */
health: number;
/** Hit by a destroy effect: still in play until the Death Creation Step. */
pendingDestroy: boolean;
}
/** An aura in force: which player's minion has it. */
export interface ActiveAura {
owner: PlayerId;
aura: Aura;
}
/**
* 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.
*/
export type Chooser = (kind: string, options: readonly string[]) => string;
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
type Side = Record<Zone, EntityId[]> & { hero: EntityId; armor: number };
export class World {
/** Indexed by EntityId. */
private readonly entities: Entity[] = [];
private readonly sides: [Side, Side];
private nextOrderOfPlay = 0;
private events: Event[] = [];
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
private auras: ActiveAura[] = [];
/** Until a chooser is set, take the first option. */
private chooser: Chooser = (_kind, options) => options[0];
/**
* 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.
*/
private rules: Aura[] = [];
/** Module 10: the steps at the end of the outermost Phase, in order. */
private endSteps: readonly EndOfPhaseStep[] = END_OF_PHASE;
/** A new game: both heroes are in play from the start, so they are the oldest entities. */
constructor(private readonly library: CardLibrary) {
const side = (owner: PlayerId): Side => {
const hero = this.entities.length;
this.entities.push({
card: "Hero",
owner,
zone: "board",
orderOfPlay: ++this.nextOrderOfPlay,
attack: 0,
health: HERO_HEALTH,
pendingDestroy: false,
});
// In play, but not on the board list: the board holds minions only.
return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 };
};
this.sides = [side(0), side(1)];
}
/**
* Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board),
* or at index `at` when given (clamped to the zone's length).
*/
create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
const { def } = this.library.get(card);
const id = this.entities.length;
this.entities.push({
card: def.name,
owner,
zone,
attack: def.attack ?? 0,
health: def.health ?? 0,
pendingDestroy: false,
});
this.enter(id, zone, at);
return id;
}
/** Move an entity to the end of `zone` on its owner's side, keeping its id. */
moveTo(id: EntityId, zone: Zone): void {
const { owner, zone: from } = this.entity(id);
const list = this.sides[owner][from];
list.splice(list.indexOf(id), 1);
this.enter(id, zone);
}
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
private enter(id: EntityId, zone: Zone, at?: number): void {
const entity = this.entity(id);
const list = this.sides[entity.owner][zone];
list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id);
entity.zone = zone;
if (zone === "board") {
entity.orderOfPlay = ++this.nextOrderOfPlay;
this.record({ type: "Summoned", id });
}
}
private entity(id: EntityId): Entity {
const entity = this.entities[id];
if (!entity) throw new Error(`unknown entity id ${id}`);
return entity;
}
// ---- Queries ----
card(name: string): Card {
return this.library.get(name);
}
hasCard(name: string): boolean {
return this.library.has(name);
}
/** The cards an Assemble may offer, sorted by name. */
pool(): Card[] {
return this.library.pool();
}
zoneOf(id: EntityId): Zone {
return this.entity(id).zone;
}
cardName(id: EntityId): string {
return this.entity(id).card;
}
ownerOf(id: EntityId): PlayerId {
return this.entity(id).owner;
}
/** When the entity last entered the board (larger is later); undefined if it never has. */
orderOfPlay(id: EntityId): number | undefined {
return this.entity(id).orderOfPlay;
}
/** The ids in one player's zone, in zone order (left to right for the board). */
zone(owner: PlayerId, zone: Zone): readonly EntityId[] {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner][zone];
}
hero(owner: PlayerId): EntityId {
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
return this.sides[owner].hero;
}
isHero(id: EntityId): boolean {
return this.sides.some((s) => s.hero === id);
}
/** Every minion on either board, oldest first (order of play). */
minionsInPlay(): EntityId[] {
return [...this.sides[0].board, ...this.sides[1].board].sort(
(a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!,
);
}
attack(id: EntityId): number {
return this.entity(id).attack;
}
health(id: EntityId): number {
return this.entity(id).health;
}
/** The cost printed on the entity's card. */
cost(id: EntityId): number {
return this.card(this.cardName(id)).def.cost;
}
/** The minions in a player's hand, in hand order. */
minionsInHand(owner: PlayerId): EntityId[] {
return this.zone(owner, "hand").filter((id) => this.card(this.cardName(id)).def.type === "minion");
}
/** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */
isDoomed(id: EntityId): boolean {
const entity = this.entity(id);
return entity.health <= 0 || entity.pendingDestroy;
}
/** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */
boardIndex(id: EntityId): number | undefined {
const index = this.sides[this.ownerOf(id)].board.indexOf(id);
return index < 0 ? undefined : index;
}
armor(owner: PlayerId): number {
return this.sides[owner].armor;
}
activeAuras(): readonly ActiveAura[] {
return this.auras;
}
/** Auras from outside the cards (Module 10), as last set. */
ruleAuras(): readonly Aura[] {
return this.rules;
}
/** The steps at the end of the outermost Phase, in order. */
endOfPhase(): readonly EndOfPhaseStep[] {
return this.endSteps;
}
/** How many entities exist: ids run from 0 to this, exclusive. */
entityCount(): number {
return this.entities.length;
}
log(): readonly Event[] {
return this.events;
}
// ---- Changes: each one records its event ----
record(event: Event): void {
this.events.push(event);
}
/** Add a card to the library, or replace the card with the same name. */
register(def: CardDef): void {
this.library.register(def);
}
/** Add a card made by Assemble (see `assemble.ts`). */
registerAssembled(def: CardDef): void {
this.library.registerAssembled(def);
}
setChooser(chooser: Chooser): void {
this.chooser = chooser;
}
/** Ask the chooser to pick one of `options` (not empty). Throws if it answers something that isn't an option. */
choose(kind: string, options: readonly string[]): string {
const answer = this.chooser(kind, options);
if (!options.includes(answer)) {
throw new Error(`chooser answered "${answer}" for "${kind}"; the options were ${JSON.stringify(options)}`);
}
return answer;
}
/** Replace the auras from outside the cards. They take effect at the next Aura Update. */
setRuleAuras(auras: Aura[]): void {
this.rules = auras;
}
/** Change the order of the steps at the end of the outermost Phase. */
setEndOfPhase(steps: readonly EndOfPhaseStep[]): void {
this.endSteps = steps;
}
/** Only an Aura Update calls this. */
setActiveAuras(auras: ActiveAura[]): void {
this.auras = auras;
}
clearLog(): void {
this.events = [];
}
damage(target: EntityId, amount: number): void {
this.entity(target).health -= amount;
this.record({ type: "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.
*/
draw(player: PlayerId): void {
const id = this.zone(player, "deck")[0];
if (id === undefined) {
this.record({ type: "Fatigue", player });
return;
}
this.moveTo(id, "hand");
this.record({ type: "Drew", player, id });
}
/** Mark for destruction. Nothing leaves play until the Death Creation Step. */
markDestroyed(id: EntityId): void {
this.entity(id).pendingDestroy = true;
}
/** Move a minion from play to its owner's graveyard, recording its Death Event. */
kill(id: EntityId, position: number): void {
this.entity(id).pendingDestroy = false;
this.moveTo(id, "graveyard");
this.record({ type: "Destroyed", id, position });
}
gainArmor(player: PlayerId, amount: number): void {
this.sides[player].armor += amount;
this.record({ type: "ArmorGained", player, amount });
}
buff(id: EntityId, attack: number, health: number): void {
const entity = this.entity(id);
entity.attack += attack;
entity.health += health;
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
}
gainAttack(id: EntityId, amount: number): void {
const entity = this.entity(id);
entity.attack += amount;
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
}
}
@@ -0,0 +1,250 @@
// Test harness: the thin adapter the course tests call. It translates each
// call into engine operations and holds no rules of its own.
import { cards, hazards, movement, seams, words } from "./content";
import {
assemble,
CardLibrary,
END_OF_PHASE,
SpellGrid as Grid,
puzzle,
registerAssembled,
runSequence,
summon,
triggerLabel,
World,
type CardDef,
type Chooser,
type Event,
type Half,
type Zone,
} from "./engine";
export { AssembleError } from "./engine";
export type { CardDef, Chooser, Half, Zone };
export type Id = number;
export type Player = 0 | 1;
/** The events the tests read from `log()` (see course/harness.md). */
export type GameEvent =
| { type: "Summoned"; id: Id }
| { type: "Damaged"; target: Id; amount: number }
| { type: "Triggered"; source: Id; trigger: string }
| { type: "Destroyed"; id: Id }
| { type: "DeathPhase"; round: number }
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */
function toGameEvent(event: Event): GameEvent | undefined {
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
// A played minion's `Summoned` already shows it entering the board.
if (event.type === "Played") return undefined;
// SpellGrid writes the text on the grid; the course's log doesn't show the request.
if (event.type === "TextSpawned") return undefined;
return { ...event };
}
export interface Game {
summon(card: string, player?: Player): Id; // to right end of board
addToHand(card: string, player?: Player): Id;
addToDeck(card: string, player?: Player): Id;
play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry)
zoneOf(id: Id): Zone;
board(player: Player): Id[]; // left to right
hand(player: Player): Id[];
orderOfPlay(id: Id): number;
nameOf(id: Id): string;
// Module 2
hero(player: Player): Id;
dealDamage(target: Id, amount: number): void; // one Sequence
cast(card: string, player?: Player, target?: Id): void;
health(id: Id): number;
attack(id: Id): number;
armor(player: Player): number;
log(): GameEvent[];
clearLog(): void;
// Module 3
destroy(target: Id): void; // marks pending destroy, one Sequence
// Module 5
registerCards(defs: CardDef[]): void; // add card definitions, replacing any with the same name
assemble(base: string, donor: string, half: Half): string; // registers "<base> (Assembled)"; throws AssembleError
cost(id: Id): number;
setChooser(choose: Chooser): void; // makes Assemble's choices: kind "base" | "donor" | "half"
}
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
const world = new World(new CardLibrary(cards()));
opts?.decks?.forEach((deck, player) => {
for (const card of deck) world.create(card, player as Player, "deck");
});
return {
summon: (card, player = 0) => summon(world, card, player),
addToHand: (card, player = 0) => world.create(card, player, "hand"),
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
// Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves.
play: (handId) => runSequence(world, { type: "play", id: handId }),
zoneOf: (id) => world.zoneOf(id),
board: (player) => [...world.zone(player, "board")],
hand: (player) => [...world.zone(player, "hand")],
orderOfPlay: (id) => {
const order = world.orderOfPlay(id);
if (order === undefined) throw new Error(`entity ${id} has not entered play`);
return order;
},
nameOf: (id) => world.cardName(id),
hero: (player) => world.hero(player),
dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }),
cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }),
health: (id) => world.health(id),
attack: (id) => world.attack(id),
armor: (player) => world.armor(player),
log: () => world.log().flatMap((event) => toGameEvent(event) ?? []),
clearLog: () => world.clearLog(),
destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }),
registerCards: (defs) => defs.forEach((def) => world.register(def)),
assemble: (base, donor, half) =>
registerAssembled(world, assemble(world.card(base).def, world.card(donor).def, half)),
cost: (id) => world.cost(id),
setChooser: (choose) => world.setChooser(choose),
};
}
// ======== Part II: Baba Is You ========
export type { Dir, LevelCard, LevelDef, LevelObject } from "./engine/puzzle";
/** A Baba Is You level in play (Part II). */
export interface Baba {
// Module 6
place(kind: string, x: number, y: number): Id; // "baba" or "text:BABA"
rules(): string[]; // distinct, normalised ("BABA IS YOU", "BABA IS NOT YOU"), sorted; before resolution
// Module 7
activeRules(): string[]; // the same, after resolution (NOT, identity)
hasProperty(id: Id, prop: string): boolean;
// Module 8
move(dir: puzzle.Dir): void; // every YOU object one tile, with what it pushes; then reparse. Movement only.
posOf(id: Id): [number, number] | null; // null once destroyed
kindsAt(x: number, y: number): string[]; // kinds as placed ("baba", "text:ROCK"), sorted
// Module 9
step(dir: puzzle.Dir): void; // one whole turn: movement, parse, conversion, parse, hazards, win (then parse)
won(): boolean; // a turn found a YOU object on a WIN one (or WIN itself); stays true
lost(): boolean; // no object is YOU
}
/** Distinct rules as normalised strings, sorted. */
const normalised = (rules: readonly puzzle.Rule[]): string[] => [...new Set(rules.map(puzzle.ruleText))].sort();
/** A level from a level definition; its text is parsed straight away. */
export function newBaba(level: puzzle.LevelDef): Baba {
const lvl = new puzzle.Level(level, words(), movement(), hazards());
return {
place: (kind, x, y) => lvl.place(kind, x, y),
rules: () => normalised(lvl.rules()),
activeRules: () => normalised(lvl.activeRules()),
hasProperty: (id, prop) => lvl.hasProperty(id, prop),
move: (dir) => lvl.moveYou(dir),
posOf: (id) => {
const o = lvl.board.find(id);
return o === undefined ? null : [o.x, o.y];
},
kindsAt: (x, y) =>
lvl.board
.at(x, y)
.map((o) => puzzle.Kind.format(o.kind))
.sort(),
step: (dir) => lvl.step(dir),
won: () => lvl.won(),
lost: () => lvl.lost(),
};
}
// ======== Part III: SpellGrid ========
/**
* A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards.
* Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.ts`).
*/
export interface SpellGrid extends Baba {
// Module 10
cast(card: string, target?: Id): void; // a spell from player 0's hand, as one Sequence; target: a minion
play(card: string, x: number, y: number): Id; // a minion from player 0's hand onto an empty tile
health(id: Id): number;
hero(player: Player): Id;
nameOf(id: Id): string;
minionAt(x: number, y: number): Id | null;
hand(): string[]; // card names in player 0's hand, in order
log(): GameEvent[]; // Part I's log, with grid ids
clearLog(): void;
setChooser(choose: Chooser): void; // makes Assemble's choices
withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step
}
/** A SpellGrid level; `extra` cards are added to the course's cards, replacing any with the same name. */
export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGrid {
const library = new CardLibrary(cards());
for (const def of extra) library.register(def);
const g = new Grid(level, library, words(), movement(), hazards(), seams());
const lvl = g.level;
/** An engine event with every entity id as a grid id. */
const withIds = (event: GameEvent): GameEvent => {
switch (event.type) {
case "Summoned":
case "Destroyed":
case "Drew":
case "StatsChanged":
return { ...event, id: g.idOf(event.id) };
case "Damaged":
return { ...event, target: g.idOf(event.target) };
case "Triggered":
return { ...event, source: g.idOf(event.source) };
default:
return event;
}
};
return {
place: (kind, x, y) => g.place(kind, x, y),
rules: () => normalised(lvl.rules()),
activeRules: () => normalised(lvl.activeRules()),
hasProperty: (id, prop) => lvl.hasProperty(id, prop),
move: (dir) => g.move(dir),
posOf: (id) => {
const o = lvl.board.find(id);
return o === undefined ? null : [o.x, o.y];
},
kindsAt: (x, y) =>
lvl.board
.at(x, y)
.map((o) => puzzle.Kind.format(o.kind))
.sort(),
step: (dir) => g.step(dir),
won: () => lvl.won(),
lost: () => lvl.lost(),
cast: (card, target) => g.cast(card, target),
play: (card, x, y) => g.play(card, x, y),
health: (id) => g.world.health(g.entity(id)),
hero: (player) => g.idOf(g.world.hero(player)),
nameOf: (id) => g.world.cardName(g.entity(id)),
minionAt: (x, y) => g.minionAt(x, y) ?? null,
hand: () => g.world.zone(0, "hand").map((id) => g.world.cardName(id)),
log: () =>
g.world
.log()
.flatMap((event) => toGameEvent(event) ?? [])
.map(withIds),
clearLog: () => g.world.clearLog(),
setChooser: (choose) => g.world.setChooser(choose),
// The naive engine reads the auras before the dead leave play: the same steps, reversed.
withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()),
};
}
@@ -0,0 +1,32 @@
// The SpellGrid command line, played from a script (the reference's own check; not a course test).
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
import { run } from "../src/cli";
function play(level: string, input: string[]): { outcome: string; out: string } {
let out = "";
const outcome = run(readFileSync(`levels/${level}.json`, "utf8"), () => input.shift(), (s) => (out += s));
return { outcome, out };
}
test("cli_wins_level_1", () => {
const { outcome, out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"]);
expect(outcome).toBe("won");
expect(out).toContain("DEA IS DOU");
expect(out).toContain("You win!");
});
test("cli_wins_level_2_with_choices_and_play", () => {
const input = ["c Graft", "Ember Moth", "Rune Scribe", "bottom", "c Ember Moth (Assembled) 4 3", "a", "a"];
const { outcome, out } = play("l2-spliced-corpse", input);
expect(outcome).toBe("won");
expect(out).toContain("Choose the base");
expect(out).toContain("Hand: Ember Moth (Assembled)");
});
test("cli_reports_errors_and_quits", () => {
const { outcome, out } = play("l3-simultaneous-paradox", ["c Spark", "x", "q"]);
expect(outcome).toBe("quit");
expect(out).toContain('"Spark" is not in the hand');
expect(out).toContain("Commands:");
});
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src", "tests"]
}
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["tests/**/*.test.ts"], passWithNoTests: true },
});