feat: module 08 push chains
Movement planned in full, then committed: a Planner builds a plan (moving and destroyed ids) by running content's movement responses (OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches. Edge of level blocks; YOU objects move furthest-ahead first; rules are reparsed once after movement. Harness: move/move_dir(Dir), posOf, kindsAt. Expansion Drop: PULL as one word plus one content response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d993457772
commit
bd80cefbef
64 files changed
+6933
No files matched your search
@@ -0,0 +1 @@
|
||||
{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "08" }
|
||||
@@ -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,17 @@
|
||||
# Module 8 reference engine (Rust)
|
||||
|
||||
The course's own engine as of the end of Module 8. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now moves objects: each `YOU` object's move is planned in full, push chain and all, and only then committed. Module 7's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` (at the crate root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it.
|
||||
- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT`, the property `PUSH` of the base rule `TEXT IS PUSH`, and from Module 8 the property `YOU`, which picks the objects that move.
|
||||
- `grid.rs`: the level format, object kinds and the board. Module 8 adds `Board::find`, `Board::remove`, `Board::shift`, `Board::at` and `Display` for `Kind` (the kind as placed, for `kinds_at`).
|
||||
- `movement.rs` (new): validate, then commit. `Planner` (`try_move`, `staying_at`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step.
|
||||
- `level.rs`: a level in play. Module 8 adds `Level::move_you`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed; the rules are reparsed once at the end. `has_property` is `false` for a destroyed object.
|
||||
- `word.rs`, `parse.rs`, `resolve.rs`: unchanged.
|
||||
- `mod.rs`: the module's exports.
|
||||
- The rest of `src/engine/`: Part I, unchanged.
|
||||
- `src/content/movement.rs` (new) and `src/content/mod.rs`: the movement responses, in the order the engine runs them: `OPEN`/`SHUT`, `PUSH`, `STOP`. What a property does when something moves is content, not engine.
|
||||
- `src/content/words.json`: Module 8 adds `OPEN` and `SHUT`.
|
||||
- `src/harness.rs`: the adapter the course tests call. Module 8 adds `Baba::move_dir`, `Baba::pos_of`, `Baba::kinds_at`, and re-exports `Dir`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 08` from the course repository.
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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 movement;
|
||||
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 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,17 @@
|
||||
[
|
||||
{ "name": "BABA", "role": "noun" },
|
||||
{ "name": "ROCK", "role": "noun" },
|
||||
{ "name": "WALL", "role": "noun" },
|
||||
{ "name": "FLAG", "role": "noun" },
|
||||
{ "name": "LAVA", "role": "noun" },
|
||||
{ "name": "KEY", "role": "noun" },
|
||||
{ "name": "DOOR", "role": "noun" },
|
||||
{ "name": "SKULL", "role": "noun" },
|
||||
{ "name": "WATER", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
{ "name": "PUSH", "role": "property" },
|
||||
{ "name": "STOP", "role": "property" },
|
||||
{ "name": "OPEN", "role": "property" },
|
||||
{ "name": "SHUT", "role": "property" }
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Assemble: "Get a random minion. Change half its text."
|
||||
//!
|
||||
//! Card text is a list of clauses, so changing half of it is list surgery: the base
|
||||
//! card's `text[..split]` (top) or `text[split..]` (bottom) is replaced by the same
|
||||
//! half of a donor card. Name, cost and stats stay the base's. This works only
|
||||
//! because a card's behaviour is its data: the new card needs no new code.
|
||||
//!
|
||||
//! Hearthstone's wiki doesn't define "half". The course's `split` field is its
|
||||
//! definition: each card's data says where its top half ends.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use super::card::{CardDef, PoolFilter};
|
||||
use super::world::{EntityId, PlayerId, World, Zone};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Half {
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Half {
|
||||
pub const BOTH: [Half; 2] = [Half::Top, Half::Bottom];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Half::Top => "top",
|
||||
Half::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssembleError {
|
||||
pub base: String,
|
||||
pub donor: String,
|
||||
pub half: Half,
|
||||
}
|
||||
|
||||
impl fmt::Display for AssembleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"assembling {:?} with the {} half of {:?} leaves its text unchanged",
|
||||
self.base,
|
||||
self.half.label(),
|
||||
self.donor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AssembleError {}
|
||||
|
||||
/// The base card with `half` of its text replaced by the same half of `donor`.
|
||||
/// It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
||||
pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result<CardDef, AssembleError> {
|
||||
let (base_top, base_bottom) = base.text.split_at(base.split);
|
||||
let (donor_top, donor_bottom) = donor.text.split_at(donor.split);
|
||||
let (top, bottom) = match half {
|
||||
Half::Top => (donor_top, base_bottom),
|
||||
Half::Bottom => (base_top, donor_bottom),
|
||||
};
|
||||
let text = [top, bottom].concat();
|
||||
if text == base.text {
|
||||
return Err(AssembleError {
|
||||
base: base.name.clone(),
|
||||
donor: donor.name.clone(),
|
||||
half,
|
||||
});
|
||||
}
|
||||
Ok(CardDef {
|
||||
name: format!("{} (Assembled)", base.name),
|
||||
split: top.len(),
|
||||
text,
|
||||
..base.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Register an assembled card and return its name. If a different card already has
|
||||
/// the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
||||
pub fn register_assembled(world: &mut World, mut def: CardDef) -> String {
|
||||
let stem = def.name.clone();
|
||||
let mut n = 1;
|
||||
while world.has_card(&def.name) && world.card(&def.name).def != def {
|
||||
n += 1;
|
||||
def.name = format!("{} {n})", stem.trim_end_matches(')'));
|
||||
}
|
||||
let name = def.name.clone();
|
||||
world.register_assembled(def);
|
||||
name
|
||||
}
|
||||
|
||||
/// The `assemble` effect: the owner's chooser picks a base minion from the pool
|
||||
/// (filtered), a donor that changes its text, and the half to replace. The
|
||||
/// Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
|
||||
/// random and offers two donors; here every choice is the chooser's.
|
||||
pub fn assemble_from_pool(
|
||||
world: &mut World,
|
||||
player: PlayerId,
|
||||
filter: &PoolFilter,
|
||||
) -> Option<EntityId> {
|
||||
// Each pool card, and whether the filter lets it be the base.
|
||||
let pool: Vec<(CardDef, bool)> = world
|
||||
.pool()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.def.clone(),
|
||||
filter.trigger.map_or(true, |on| c.has_trigger(on)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let bases: Vec<String> = pool
|
||||
.iter()
|
||||
.filter(|(_, allowed)| *allowed)
|
||||
.map(|(d, _)| d.name.clone())
|
||||
.collect();
|
||||
if bases.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let base_name = world.choose("base", &bases);
|
||||
let base = world.card(&base_name).def.clone();
|
||||
|
||||
let changes = |donor: &CardDef, half: Half| assemble(&base, donor, half).is_ok();
|
||||
let donors: Vec<String> = pool
|
||||
.iter()
|
||||
.map(|(d, _)| d)
|
||||
.filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(d, h)))
|
||||
.map(|d| d.name.clone())
|
||||
.collect();
|
||||
if donors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let donor_name = world.choose("donor", &donors);
|
||||
let donor = world.card(&donor_name).def.clone();
|
||||
|
||||
let halves: Vec<String> = Half::BOTH
|
||||
.iter()
|
||||
.filter(|&&h| changes(&donor, h))
|
||||
.map(|h| h.label().to_string())
|
||||
.collect();
|
||||
let half = world.choose("half", &halves);
|
||||
let half = if half == "top" {
|
||||
Half::Top
|
||||
} else {
|
||||
Half::Bottom
|
||||
};
|
||||
|
||||
let def = assemble(&base, &donor, half).expect("only halves that change the text are offered");
|
||||
let name = register_assembled(world, def);
|
||||
Some(world.create(&name, player, Zone::Hand))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Auras, and the one routine that applies trigger multipliers.
|
||||
//!
|
||||
//! An aura is data on a card: a continuous effect its minion has while in play.
|
||||
//! The engine never asks "is that card on the board?" in the middle of resolving
|
||||
//! something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
//! Update) and every rule reads the snapshot.
|
||||
//!
|
||||
//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
//! Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
//! Summoning a minion also runs an Aura Update.
|
||||
|
||||
use super::card::{Aura, Stacking, TriggerOn};
|
||||
use super::world::{ActiveAura, PlayerId, World};
|
||||
|
||||
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
|
||||
pub fn aura_update_other(world: &mut World) {
|
||||
let mut active = Vec::new();
|
||||
for id in world.minions_in_play() {
|
||||
for &aura in &world.card(world.card_name(id)).auras {
|
||||
active.push(ActiveAura {
|
||||
owner: world.owner_of(id),
|
||||
aura,
|
||||
});
|
||||
}
|
||||
}
|
||||
world.set_active_auras(active);
|
||||
}
|
||||
|
||||
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
|
||||
/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations.
|
||||
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
|
||||
let mut max = 1;
|
||||
let mut extra = 0;
|
||||
for active in world.active_auras() {
|
||||
let Aura::TriggerMultiplier {
|
||||
trigger,
|
||||
times,
|
||||
stacking,
|
||||
} = active.aura;
|
||||
if active.owner != owner || trigger != on {
|
||||
continue;
|
||||
}
|
||||
match stacking {
|
||||
Stacking::Max => max = max.max(times),
|
||||
Stacking::Additive => extra += times - 1,
|
||||
}
|
||||
}
|
||||
max + extra
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
||||
//! read straight from JSON. The engine compiles each definition once, when it is
|
||||
//! registered, into the lists it reads while resolving: triggers, auras and a
|
||||
//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// What a triggered ability listens for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TriggerOn {
|
||||
/// This minion takes damage.
|
||||
SelfDamaged,
|
||||
/// A minion with the same owner (this one included) takes damage.
|
||||
FriendlyMinionDamaged,
|
||||
/// Any minion takes damage.
|
||||
AnyMinionDamaged,
|
||||
/// Either hero takes damage.
|
||||
HeroDamaged,
|
||||
/// This minion dies. It resolves after the minion has left play.
|
||||
Deathrattle,
|
||||
/// This minion is played from hand.
|
||||
Battlecry,
|
||||
/// The end of its owner's turn. The course has no turns, so it never fires; card
|
||||
/// data may still name it, for example in a trigger multiplier.
|
||||
EndOfTurn,
|
||||
}
|
||||
|
||||
impl TriggerOn {
|
||||
/// The trigger's family name, as reported in `Triggered` events.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TriggerOn::SelfDamaged
|
||||
| TriggerOn::FriendlyMinionDamaged
|
||||
| TriggerOn::AnyMinionDamaged
|
||||
| TriggerOn::HeroDamaged => "damaged",
|
||||
TriggerOn::Deathrattle => "deathrattle",
|
||||
TriggerOn::Battlecry => "battlecry",
|
||||
TriggerOn::EndOfTurn => "endOfTurn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How several trigger multipliers for the same trigger combine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Stacking {
|
||||
/// The largest multiplier wins: two copies of a doubler still double.
|
||||
Max,
|
||||
/// Each copy adds its extra activations.
|
||||
Additive,
|
||||
}
|
||||
|
||||
/// An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Aura {
|
||||
/// Triggers listening for `trigger`, belonging to minions with the aura owner's
|
||||
/// owner, activate `times` times each.
|
||||
TriggerMultiplier {
|
||||
trigger: TriggerOn,
|
||||
times: u32,
|
||||
stacking: Stacking,
|
||||
},
|
||||
}
|
||||
|
||||
/// Who an effect touches. `This` is the schema's `"self"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Target {
|
||||
#[serde(rename = "self")]
|
||||
This,
|
||||
AllMinions,
|
||||
AllOtherMinions,
|
||||
AllOtherCharacters,
|
||||
EnemyHero,
|
||||
Chosen,
|
||||
}
|
||||
|
||||
/// Which minions an Assemble may pick as its base. An empty filter allows any.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct PoolFilter {
|
||||
/// Only minions with a trigger clause listening for this.
|
||||
#[serde(default)]
|
||||
pub trigger: Option<TriggerOn>,
|
||||
}
|
||||
|
||||
/// One effect: a node of a card's ability, interpreted by `effect.rs`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Effect {
|
||||
DealDamage {
|
||||
target: Target,
|
||||
amount: i32,
|
||||
},
|
||||
Draw {
|
||||
count: u32,
|
||||
},
|
||||
Summon {
|
||||
card: String,
|
||||
count: u32,
|
||||
},
|
||||
GainArmor {
|
||||
amount: i32,
|
||||
},
|
||||
GainAttack {
|
||||
target: Target,
|
||||
amount: i32,
|
||||
},
|
||||
Destroy {
|
||||
target: Target,
|
||||
},
|
||||
/// Part III (SpellGrid) only: puts a word on the grid.
|
||||
SpawnText {
|
||||
word: String,
|
||||
at: String,
|
||||
},
|
||||
/// Get a minion from the pool, with half its text replaced (see `assemble.rs`).
|
||||
Assemble {
|
||||
filter: PoolFilter,
|
||||
},
|
||||
/// Give every minion in the owner's hand +attack/+health.
|
||||
BuffHand {
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// One line of card text. A trigger clause applies to the effect clauses after it,
|
||||
/// until the next trigger, keyword or aura. Effects before any trigger are what a
|
||||
/// spell does when cast.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Clause {
|
||||
Keyword { keyword: String },
|
||||
Trigger { on: TriggerOn },
|
||||
Aura { modifier: Aura },
|
||||
Effect { effect: Effect },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CardType {
|
||||
Minion,
|
||||
Spell,
|
||||
}
|
||||
|
||||
/// A card as data, in the course's card format. Fields the engine doesn't use
|
||||
/// (`fictional`, `game`, `noun`) are ignored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct CardDef {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub card_type: CardType,
|
||||
pub cost: i32,
|
||||
#[serde(default)]
|
||||
pub attack: i32,
|
||||
#[serde(default)]
|
||||
pub health: i32,
|
||||
pub text: Vec<Clause>,
|
||||
/// `text[..split]` is the top half, `text[split..]` the bottom half.
|
||||
pub split: usize,
|
||||
}
|
||||
|
||||
/// A triggered ability: "whenever <on>, <effects>".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Trigger {
|
||||
pub on: TriggerOn,
|
||||
pub effects: Vec<Effect>,
|
||||
}
|
||||
|
||||
/// A registered card: its definition, and the definition's text compiled into the
|
||||
/// lists the engine reads while resolving.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Card {
|
||||
pub def: CardDef,
|
||||
/// Triggered abilities, in printed order.
|
||||
pub triggers: Vec<Trigger>,
|
||||
/// Auras this minion has while in play.
|
||||
pub auras: Vec<Aura>,
|
||||
/// What the card does when cast (a spell's effects).
|
||||
pub on_cast: Vec<Effect>,
|
||||
/// May an Assemble offer this card? Minions with text that were not themselves assembled.
|
||||
pub in_pool: bool,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// Compile a definition's text. Panics if `split` is past the end of the text.
|
||||
pub fn compile(def: CardDef, in_pool: bool) -> Card {
|
||||
assert!(
|
||||
def.split <= def.text.len(),
|
||||
"{:?}: split {} is past the end of its text",
|
||||
def.name,
|
||||
def.split
|
||||
);
|
||||
let mut triggers: Vec<Trigger> = Vec::new();
|
||||
let mut auras = Vec::new();
|
||||
let mut on_cast = Vec::new();
|
||||
// Is the latest clause a trigger whose effects are still being listed?
|
||||
let mut open_trigger = false;
|
||||
for clause in &def.text {
|
||||
match clause {
|
||||
Clause::Trigger { on } => {
|
||||
triggers.push(Trigger {
|
||||
on: *on,
|
||||
effects: Vec::new(),
|
||||
});
|
||||
open_trigger = true;
|
||||
}
|
||||
Clause::Effect { effect } if open_trigger => {
|
||||
triggers.last_mut().unwrap().effects.push(effect.clone());
|
||||
}
|
||||
Clause::Effect { effect } => on_cast.push(effect.clone()),
|
||||
Clause::Aura { modifier } => {
|
||||
auras.push(*modifier);
|
||||
open_trigger = false;
|
||||
}
|
||||
// The course has no combat, so keywords such as Taunt do nothing here.
|
||||
Clause::Keyword { .. } => open_trigger = false,
|
||||
}
|
||||
}
|
||||
let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty();
|
||||
Card {
|
||||
def,
|
||||
triggers,
|
||||
auras,
|
||||
on_cast,
|
||||
in_pool,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_spell(&self) -> bool {
|
||||
self.def.card_type == CardType::Spell
|
||||
}
|
||||
|
||||
pub fn has_trigger(&self, on: TriggerOn) -> bool {
|
||||
self.triggers.iter().any(|t| t.on == on)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every card the game can create, looked up by name.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CardLibrary {
|
||||
cards: HashMap<String, Card>,
|
||||
}
|
||||
|
||||
impl CardLibrary {
|
||||
pub fn new(defs: Vec<CardDef>) -> CardLibrary {
|
||||
let mut library = CardLibrary::default();
|
||||
for def in defs {
|
||||
library.register(def);
|
||||
}
|
||||
library
|
||||
}
|
||||
|
||||
/// Add a card, or replace the card with the same name. It joins the Assemble pool
|
||||
/// if it is a minion with text.
|
||||
pub fn register(&mut self, def: CardDef) {
|
||||
self.insert(Card::compile(def, true));
|
||||
}
|
||||
|
||||
/// Add a card made by Assemble. It never joins the pool.
|
||||
pub fn register_assembled(&mut self, def: CardDef) {
|
||||
self.insert(Card::compile(def, false));
|
||||
}
|
||||
|
||||
fn insert(&mut self, card: Card) {
|
||||
self.cards.insert(card.def.name.clone(), card);
|
||||
}
|
||||
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.cards.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> &Card {
|
||||
self.cards
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("unknown card {name:?}: add its file to cards/"))
|
||||
}
|
||||
|
||||
/// The Assemble pool: every card with `in_pool`, sorted by name.
|
||||
pub fn pool(&self) -> Vec<&Card> {
|
||||
let mut pool: Vec<&Card> = self.cards.values().filter(|c| c.in_pool).collect();
|
||||
pool.sort_by(|a, b| a.def.name.cmp(&b.def.name));
|
||||
pool
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The Death Creation Step: the one place minions leave play.
|
||||
//!
|
||||
//! Damage and destroy effects only mark a minion (0 or less Health, or pending
|
||||
//! destroy). It stays in play, and keeps triggering, until the outermost Phase
|
||||
//! ends. Then this step removes every marked minion at once.
|
||||
|
||||
use super::event::Event;
|
||||
use super::world::World;
|
||||
|
||||
/// Remove every mortally wounded or pending-destroy minion from play, simultaneously.
|
||||
/// Returns their Death Events in order of play; empty if nothing died.
|
||||
pub fn death_creation_step(world: &mut World) -> Vec<Event> {
|
||||
// Every position is read before anything moves: removal is simultaneous, and each
|
||||
// Death Event remembers where its minion stood when the step began.
|
||||
let mut deaths = Vec::new();
|
||||
for id in world.minions_in_play() {
|
||||
if world.is_doomed(id) {
|
||||
let position = world
|
||||
.board_index(id)
|
||||
.expect("a minion in play is on the board");
|
||||
deaths.push(Event::Destroyed { id, position });
|
||||
}
|
||||
}
|
||||
for death in &deaths {
|
||||
if let Event::Destroyed { id, position } = *death {
|
||||
world.kill(id, position);
|
||||
}
|
||||
}
|
||||
deaths
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! The interpreter: turns one effect, as data, into engine actions.
|
||||
//!
|
||||
//! This is the only place that knows what an effect *means*. A card's text is a
|
||||
//! small tree (clauses holding effects holding targets); the engine walks it. The
|
||||
//! effect vocabulary is closed: a card can only do what this match can do, which is
|
||||
//! what makes a data card safe to load.
|
||||
|
||||
use super::card::{Effect, Target};
|
||||
use super::queue::{Action, Ctx};
|
||||
use super::world::EntityId;
|
||||
|
||||
/// The actions one effect performs, in order, given where and for whom it runs.
|
||||
pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec<Action> {
|
||||
let owner = ctx.owner;
|
||||
match effect {
|
||||
Effect::DealDamage { target, amount } => vec![Action::Damage {
|
||||
targets: targets(*target, ctx),
|
||||
amount: *amount,
|
||||
}],
|
||||
Effect::Draw { count } => (0..*count)
|
||||
.map(|_| Action::Draw { player: owner })
|
||||
.collect(),
|
||||
Effect::Summon { card, count } => (0..*count)
|
||||
.map(|_| Action::Summon {
|
||||
card: card.clone(),
|
||||
player: owner,
|
||||
at: summon_position(ctx),
|
||||
})
|
||||
.collect(),
|
||||
Effect::GainArmor { amount } => vec![Action::GainArmor {
|
||||
player: owner,
|
||||
amount: *amount,
|
||||
}],
|
||||
Effect::GainAttack { target, amount } => targets(*target, ctx)
|
||||
.into_iter()
|
||||
.map(|id| Action::GainAttack {
|
||||
id,
|
||||
amount: *amount,
|
||||
})
|
||||
.collect(),
|
||||
Effect::Destroy { target } => vec![Action::Destroy {
|
||||
targets: targets(*target, ctx),
|
||||
}],
|
||||
Effect::Assemble { filter } => vec![Action::Assemble {
|
||||
player: owner,
|
||||
filter: filter.clone(),
|
||||
}],
|
||||
Effect::BuffHand { attack, health } => ctx
|
||||
.world
|
||||
.minions_in_hand(owner)
|
||||
.into_iter()
|
||||
.map(|id| Action::Buff {
|
||||
id,
|
||||
attack: *attack,
|
||||
health: *health,
|
||||
})
|
||||
.collect(),
|
||||
Effect::SpawnText { .. } => panic!("spawnText needs a grid: it is a Part III effect"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entities a target names, from where the effect runs.
|
||||
fn targets(target: Target, ctx: &Ctx) -> Vec<EntityId> {
|
||||
let world = ctx.world;
|
||||
let others = |ids: Vec<EntityId>| -> Vec<EntityId> {
|
||||
ids.into_iter()
|
||||
.filter(|&id| Some(id) != ctx.source)
|
||||
.collect()
|
||||
};
|
||||
match target {
|
||||
Target::This => vec![ctx.minion()],
|
||||
Target::AllMinions => world.minions_in_play(),
|
||||
Target::AllOtherMinions => others(world.minions_in_play()),
|
||||
Target::AllOtherCharacters => {
|
||||
let mut ids = vec![world.hero(0), world.hero(1)];
|
||||
ids.extend(world.minions_in_play());
|
||||
others(ids)
|
||||
}
|
||||
Target::EnemyHero => vec![ctx.enemy_hero()],
|
||||
Target::Chosen => ctx.target.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a summon goes: to the right of a source still on the board; where a dead
|
||||
/// source stood (its Death Event's position); at the right end for a spell.
|
||||
fn summon_position(ctx: &Ctx) -> Option<usize> {
|
||||
match ctx.source.and_then(|s| ctx.world.board_index(s)) {
|
||||
Some(index) => Some(index + 1),
|
||||
None => ctx.position,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! What happened, in order. The engine records an event each time it changes the game.
|
||||
|
||||
use super::card::TriggerOn;
|
||||
use super::world::{EntityId, PlayerId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
Summoned {
|
||||
id: EntityId,
|
||||
},
|
||||
Damaged {
|
||||
target: EntityId,
|
||||
amount: i32,
|
||||
},
|
||||
Triggered {
|
||||
source: EntityId,
|
||||
on: TriggerOn,
|
||||
},
|
||||
Drew {
|
||||
player: PlayerId,
|
||||
id: EntityId,
|
||||
},
|
||||
Fatigue {
|
||||
player: PlayerId,
|
||||
},
|
||||
ArmorGained {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
StatsChanged {
|
||||
id: EntityId,
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
SpellCast {
|
||||
card: String,
|
||||
},
|
||||
/// A Death Event: the Death Creation Step moved `id` from play to the graveyard.
|
||||
/// `position` is the board index it had when the step began; its Deathrattle summons there.
|
||||
Destroyed {
|
||||
id: EntityId,
|
||||
position: usize,
|
||||
},
|
||||
DeathPhase {
|
||||
round: u32,
|
||||
},
|
||||
/// A minion was played from hand (it has just entered the board). Its Battlecry answers this.
|
||||
Played {
|
||||
id: EntityId,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 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.
|
||||
|
||||
mod assemble;
|
||||
mod aura;
|
||||
mod card;
|
||||
mod death;
|
||||
mod effect;
|
||||
mod event;
|
||||
pub mod puzzle;
|
||||
mod queue;
|
||||
mod world;
|
||||
|
||||
pub use assemble::{assemble, register_assembled, AssembleError, Half};
|
||||
pub use card::{
|
||||
Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
|
||||
Trigger, TriggerOn,
|
||||
};
|
||||
pub use event::Event;
|
||||
pub use queue::{run_sequence, summon, Action, Ctx};
|
||||
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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,124 @@
|
||||
//! 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.
|
||||
|
||||
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::word::{Lexicon, WordDef};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Level {
|
||||
board: Board,
|
||||
lexicon: Lexicon,
|
||||
responses: Vec<Response>,
|
||||
rules: Vec<Rule>,
|
||||
active: Vec<Rule>,
|
||||
}
|
||||
|
||||
/// The property that makes an object move when the player moves.
|
||||
pub const YOU: &str = "YOU";
|
||||
|
||||
impl Level {
|
||||
/// A level from its definition, the words content provides, and content's
|
||||
/// movement responses, in the order they are to run.
|
||||
pub fn new(def: &LevelDef, words: &[WordDef], responses: Vec<Response>) -> Level {
|
||||
let mut level = Level {
|
||||
board: Board::new(def.width, def.height),
|
||||
lexicon: Lexicon::new(words),
|
||||
responses,
|
||||
rules: vec![],
|
||||
active: vec![],
|
||||
};
|
||||
for o in &def.objects {
|
||||
level.board.place(Kind::parse(&o.kind), o.x, o.y);
|
||||
}
|
||||
level.reparse();
|
||||
level
|
||||
}
|
||||
|
||||
pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId {
|
||||
let id = self.board.place(Kind::parse(kind), x, y);
|
||||
self.reparse();
|
||||
id
|
||||
}
|
||||
|
||||
pub fn board(&self) -> &Board {
|
||||
&self.board
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Move every `YOU` object one tile in `dir`, then reparse. 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.
|
||||
pub fn move_you(&mut self, dir: Dir) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.reparse();
|
||||
}
|
||||
|
||||
fn commit(&mut self, plan: &Plan, dir: Dir) {
|
||||
for &id in &plan.destroyed {
|
||||
self.board.remove(id);
|
||||
}
|
||||
for &id in plan.moving.difference(&plan.destroyed) {
|
||||
self.board.shift(id, dir.delta());
|
||||
}
|
||||
}
|
||||
|
||||
fn reparse(&mut self) {
|
||||
self.rules = parse(&self.board, &self.lexicon);
|
||||
self.active = resolve(&self.rules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! 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.
|
||||
|
||||
mod grid;
|
||||
mod level;
|
||||
mod movement;
|
||||
mod parse;
|
||||
mod resolve;
|
||||
mod word;
|
||||
|
||||
pub use grid::{Board, Kind, LevelDef, LevelObject, Object, ObjectId};
|
||||
pub use level::{Level, YOU};
|
||||
pub use movement::{Dir, Plan, Planner, Response, Step};
|
||||
pub use parse::{parse, Rule, BASE_RULES};
|
||||
pub use resolve::resolve;
|
||||
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,119 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of text the parser can read: which object, which word, which role.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
pub id: ObjectId,
|
||||
pub word: String,
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
/// The words on the board, by tile; a tile's words in object order (oldest first).
|
||||
/// Text whose word the lexicon doesn't know is left out.
|
||||
pub fn lex(board: &Board, lexicon: &Lexicon) -> BTreeMap<(i32, i32), Vec<Token>> {
|
||||
let mut tiles: BTreeMap<(i32, i32), Vec<Token>> = BTreeMap::new();
|
||||
for object in board.objects() {
|
||||
let Kind::Text { word } = &object.kind else {
|
||||
continue;
|
||||
};
|
||||
if let Some(role) = lexicon.role(word) {
|
||||
tiles.entry((object.x, object.y)).or_default().push(Token {
|
||||
id: object.id,
|
||||
word: word.clone(),
|
||||
role,
|
||||
});
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The action queue: how one player action (a Sequence) resolves.
|
||||
//!
|
||||
//! Performing an action changes the world and produces events. Every trigger that
|
||||
//! responds to those events goes into one Queue, oldest listener first, and the Queue
|
||||
//! is locked. Each queued trigger then resolves completely, including everything
|
||||
//! its own actions cause, before the next one starts: depth-first.
|
||||
//!
|
||||
//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
|
||||
//! Death Creation Step removes the dead, and a Death Phase resolves their
|
||||
//! Deathrattles. That repeats until a step finds no new deaths.
|
||||
//!
|
||||
//! Module 4: a queued trigger may activate more than once. How many times is
|
||||
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
|
||||
//! Nothing here knows which cards multiply which triggers.
|
||||
//!
|
||||
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
|
||||
//! its actions performed before the next effect of the same ability is read, so
|
||||
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
|
||||
|
||||
use super::assemble::assemble_from_pool;
|
||||
use super::aura::{activations, aura_update_other};
|
||||
use super::card::{Effect, PoolFilter, TriggerOn};
|
||||
use super::death::death_creation_step;
|
||||
use super::effect::actions;
|
||||
use super::event::Event;
|
||||
use super::world::{EntityId, PlayerId, World, Zone};
|
||||
|
||||
/// Something the engine can do. Card effects return these instead of changing the world.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Deal `amount` to each target at once: all damage first, then the reactions.
|
||||
Damage {
|
||||
targets: Vec<EntityId>,
|
||||
amount: i32,
|
||||
},
|
||||
Draw {
|
||||
player: PlayerId,
|
||||
},
|
||||
GainArmor {
|
||||
player: PlayerId,
|
||||
amount: i32,
|
||||
},
|
||||
GainAttack {
|
||||
id: EntityId,
|
||||
amount: i32,
|
||||
},
|
||||
Cast {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
target: Option<EntityId>,
|
||||
},
|
||||
/// Mark each target pending destroy. They leave play in the next Death Creation Step.
|
||||
Destroy {
|
||||
targets: Vec<EntityId>,
|
||||
},
|
||||
/// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`.
|
||||
Summon {
|
||||
card: String,
|
||||
player: PlayerId,
|
||||
at: Option<usize>,
|
||||
},
|
||||
/// Play a minion from its owner's hand: it enters the right end of the board, then its
|
||||
/// Battlecry resolves.
|
||||
Play {
|
||||
id: EntityId,
|
||||
},
|
||||
/// Put an Assembled minion into `player`'s hand (see `assemble.rs`).
|
||||
Assemble {
|
||||
player: PlayerId,
|
||||
filter: PoolFilter,
|
||||
},
|
||||
/// Give one entity +attack/+health.
|
||||
Buff {
|
||||
id: EntityId,
|
||||
attack: i32,
|
||||
health: i32,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an effect may look at when it runs.
|
||||
pub struct Ctx<'a> {
|
||||
pub world: &'a World,
|
||||
/// The player the effect works for.
|
||||
pub owner: PlayerId,
|
||||
/// The minion whose trigger is resolving; `None` for a spell.
|
||||
pub source: Option<EntityId>,
|
||||
/// The chosen target, if any.
|
||||
pub target: Option<EntityId>,
|
||||
/// The source's board position. For a Deathrattle, the source has left play,
|
||||
/// and this is the position its Death Event remembered.
|
||||
pub position: Option<usize>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// The minion whose trigger is resolving.
|
||||
pub fn minion(&self) -> EntityId {
|
||||
self.source.expect("only a triggered ability has a source")
|
||||
}
|
||||
|
||||
pub fn enemy_hero(&self) -> EntityId {
|
||||
self.world.hero(1 - self.owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one Sequence: a player action and everything it causes.
|
||||
pub fn run_sequence(world: &mut World, action: Action) {
|
||||
perform(world, action);
|
||||
// The outermost Phase has ended. Each Death Phase is itself an outermost
|
||||
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
|
||||
let mut deaths = end_of_outermost_phase(world);
|
||||
let mut round = 0;
|
||||
while !deaths.is_empty() {
|
||||
round += 1;
|
||||
world.record(Event::DeathPhase { round });
|
||||
react(world, &deaths);
|
||||
deaths = end_of_outermost_phase(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps that run only when the outermost Phase ends; returns the Death Events.
|
||||
/// Hearthstone runs an Aura Update (Health/Attack) first. The course has no
|
||||
/// Health/Attack auras, so that step has nothing to do and is left out.
|
||||
fn end_of_outermost_phase(world: &mut World) -> Vec<Event> {
|
||||
let deaths = death_creation_step(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
aura_update_other(world);
|
||||
deaths
|
||||
}
|
||||
|
||||
/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an
|
||||
/// Aura Update.
|
||||
pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option<usize>) -> EntityId {
|
||||
let id = world.create_at(card, player, Zone::Board, at);
|
||||
aura_update_other(world);
|
||||
id
|
||||
}
|
||||
|
||||
fn perform(world: &mut World, action: Action) {
|
||||
match action {
|
||||
Action::Damage {
|
||||
mut targets,
|
||||
amount,
|
||||
} => {
|
||||
// Area damage: every Damage Event is created, in order of play, before any reaction.
|
||||
targets.sort_by_key(|&id| world.order_of_play(id));
|
||||
let mut events = Vec::new();
|
||||
for target in targets {
|
||||
world.damage(target, amount);
|
||||
events.push(Event::Damaged { target, amount });
|
||||
}
|
||||
react(world, &events);
|
||||
}
|
||||
Action::Draw { player } => world.draw(player),
|
||||
Action::GainArmor { player, amount } => world.gain_armor(player, amount),
|
||||
Action::GainAttack { id, amount } => world.gain_attack(id, amount),
|
||||
Action::Destroy { targets } => {
|
||||
for target in targets {
|
||||
world.mark_destroyed(target);
|
||||
}
|
||||
}
|
||||
Action::Summon { card, player, at } => {
|
||||
summon(world, &card, player, at);
|
||||
}
|
||||
Action::Play { id } => {
|
||||
assert!(
|
||||
world.zone_of(id) == Zone::Hand,
|
||||
"play: entity {id} is not in a hand"
|
||||
);
|
||||
// Playing is not summoning: no Aura Update here. The played minion's own
|
||||
// auras join at the end of the outermost Phase.
|
||||
world.move_to(id, Zone::Board);
|
||||
let played = Event::Played { id };
|
||||
world.record(played.clone());
|
||||
react(world, &[played]);
|
||||
}
|
||||
Action::Cast {
|
||||
card,
|
||||
player,
|
||||
target,
|
||||
} => {
|
||||
let spell = world.card(&card);
|
||||
assert!(spell.is_spell(), "{card:?} is not a spell");
|
||||
let effects = spell.on_cast.clone();
|
||||
world.record(Event::SpellCast { card });
|
||||
run_effects(world, &effects, player, None, target, None);
|
||||
}
|
||||
Action::Assemble { player, filter } => {
|
||||
assemble_from_pool(world, player, &filter);
|
||||
}
|
||||
Action::Buff { id, attack, health } => world.buff(id, attack, health),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret and perform an ability's effects in printed order. Each effect sees the
|
||||
/// world as the effects before it left it. Depth-first: each action, and every
|
||||
/// reaction it causes, finishes before the next.
|
||||
fn run_effects(
|
||||
world: &mut World,
|
||||
effects: &[Effect],
|
||||
owner: PlayerId,
|
||||
source: Option<EntityId>,
|
||||
target: Option<EntityId>,
|
||||
position: Option<usize>,
|
||||
) {
|
||||
for effect in effects {
|
||||
let ctx = Ctx {
|
||||
world,
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
position,
|
||||
};
|
||||
for action in actions(effect, &ctx) {
|
||||
perform(world, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
|
||||
fn react(world: &mut World, events: &[Event]) {
|
||||
// Fill the Queue: for each event (already in order of play), each listener in order of play.
|
||||
let mut queue = Vec::new();
|
||||
for event in events {
|
||||
for listener in listeners(world, event) {
|
||||
for trigger in &world.card(world.card_name(listener)).triggers {
|
||||
if responds(world, trigger.on, listener, event) {
|
||||
queue.push((listener, trigger.clone(), event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Queue is now locked: nothing that happens below adds to it.
|
||||
for (source, trigger, event) in queue {
|
||||
// The interceptor: the entry was queued once; the auras in force when it
|
||||
// starts decide how many times it activates, back to back.
|
||||
let times = activations(world, world.owner_of(source), trigger.on);
|
||||
for _ in 0..times {
|
||||
world.record(Event::Triggered {
|
||||
source,
|
||||
on: trigger.on,
|
||||
});
|
||||
let position = match *event {
|
||||
Event::Destroyed { id, position } if id == source => Some(position),
|
||||
_ => world.board_index(source),
|
||||
};
|
||||
let owner = world.owner_of(source);
|
||||
run_effects(world, &trigger.effects, owner, Some(source), None, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who may respond to `event`, in order of play: every minion in play and, for a
|
||||
/// Death Event, the minion that died. It has left play, but its Deathrattle still answers.
|
||||
fn listeners(world: &World, event: &Event) -> Vec<EntityId> {
|
||||
let mut ids = world.minions_in_play();
|
||||
if let Event::Destroyed { id, .. } = *event {
|
||||
ids.push(id);
|
||||
ids.sort_by_key(|&id| world.order_of_play(id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`?
|
||||
fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool {
|
||||
if let Event::Destroyed { id, .. } = *event {
|
||||
return on == TriggerOn::Deathrattle && id == listener;
|
||||
}
|
||||
if let Event::Played { id } = *event {
|
||||
return on == TriggerOn::Battlecry && id == listener;
|
||||
}
|
||||
let Event::Damaged { target, .. } = *event else {
|
||||
return false;
|
||||
};
|
||||
let is_minion = !world.is_hero(target);
|
||||
match on {
|
||||
TriggerOn::SelfDamaged => target == listener,
|
||||
TriggerOn::FriendlyMinionDamaged => {
|
||||
is_minion && world.owner_of(target) == world.owner_of(listener)
|
||||
}
|
||||
TriggerOn::AnyMinionDamaged => is_minion,
|
||||
TriggerOn::HeroDamaged => !is_minion,
|
||||
TriggerOn::Deathrattle | TriggerOn::Battlecry | TriggerOn::EndOfTurn => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Entities, zones, order of play, stats and the event log: the game's state.
|
||||
//! The rules for resolving actions live in `queue.rs`.
|
||||
|
||||
use super::card::{Aura, Card, CardDef, CardLibrary};
|
||||
use super::event::Event;
|
||||
|
||||
/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone.
|
||||
pub type EntityId = u32;
|
||||
pub type PlayerId = u8;
|
||||
|
||||
/// Starting Health of each hero.
|
||||
pub const HERO_HEALTH: i32 = 30;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Zone {
|
||||
Deck,
|
||||
Hand,
|
||||
Board,
|
||||
Graveyard,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Entity {
|
||||
card: String,
|
||||
owner: PlayerId,
|
||||
zone: Zone,
|
||||
/// Stamped each time the entity enters the board; `None` until it first does.
|
||||
order_of_play: Option<u32>,
|
||||
attack: i32,
|
||||
/// Current Health. At 0 or below the entity is mortally wounded: still in play until the
|
||||
/// Death Creation Step.
|
||||
health: i32,
|
||||
/// Hit by a destroy effect: still in play until the Death Creation Step.
|
||||
pending_destroy: bool,
|
||||
}
|
||||
|
||||
/// An aura in force: which player's minion has it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ActiveAura {
|
||||
pub owner: PlayerId,
|
||||
pub aura: Aura,
|
||||
}
|
||||
|
||||
/// One player's zones. Each is an ordered list of ids (the board is left to right).
|
||||
#[derive(Debug, Default)]
|
||||
struct Side {
|
||||
deck: Vec<EntityId>,
|
||||
hand: Vec<EntityId>,
|
||||
board: Vec<EntityId>,
|
||||
graveyard: Vec<EntityId>,
|
||||
hero: EntityId,
|
||||
armor: i32,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn zone(&self, zone: Zone) -> &Vec<EntityId> {
|
||||
match zone {
|
||||
Zone::Deck => &self.deck,
|
||||
Zone::Hand => &self.hand,
|
||||
Zone::Board => &self.board,
|
||||
Zone::Graveyard => &self.graveyard,
|
||||
}
|
||||
}
|
||||
|
||||
fn zone_mut(&mut self, zone: Zone) -> &mut Vec<EntityId> {
|
||||
match zone {
|
||||
Zone::Deck => &mut self.deck,
|
||||
Zone::Hand => &mut self.hand,
|
||||
Zone::Board => &mut self.board,
|
||||
Zone::Graveyard => &mut self.graveyard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a player's choices: called with what is being chosen and the options, it
|
||||
/// returns one of the options. Hearthstone would ask the player or pick at random.
|
||||
pub type Chooser = Box<dyn FnMut(&str, &[String]) -> String>;
|
||||
|
||||
pub struct World {
|
||||
library: CardLibrary,
|
||||
chooser: Chooser,
|
||||
/// Indexed by `EntityId`.
|
||||
entities: Vec<Entity>,
|
||||
sides: [Side; 2],
|
||||
next_order_of_play: u32,
|
||||
log: Vec<Event>,
|
||||
/// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`).
|
||||
auras: Vec<ActiveAura>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// A new game: both heroes are in play from the start, so they are the oldest entities.
|
||||
pub fn new(library: CardLibrary) -> World {
|
||||
let mut world = World {
|
||||
library,
|
||||
// Until a chooser is set, take the first option.
|
||||
chooser: Box::new(|_, options| options[0].clone()),
|
||||
entities: Vec::new(),
|
||||
sides: Default::default(),
|
||||
next_order_of_play: 0,
|
||||
log: Vec::new(),
|
||||
auras: Vec::new(),
|
||||
};
|
||||
for owner in 0..2 {
|
||||
let id = world.entities.len() as EntityId;
|
||||
world.next_order_of_play += 1;
|
||||
world.entities.push(Entity {
|
||||
card: "Hero".to_string(),
|
||||
owner,
|
||||
zone: Zone::Board,
|
||||
order_of_play: Some(world.next_order_of_play),
|
||||
attack: 0,
|
||||
health: HERO_HEALTH,
|
||||
pending_destroy: false,
|
||||
});
|
||||
// In play, but not on the board list: the board holds minions only.
|
||||
world.sides[owner as usize].hero = id;
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
/// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board).
|
||||
pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId {
|
||||
self.create_at(card, owner, zone, None)
|
||||
}
|
||||
|
||||
/// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the
|
||||
/// zone's length), or at the end if `at` is `None`.
|
||||
pub fn create_at(
|
||||
&mut self,
|
||||
card: &str,
|
||||
owner: PlayerId,
|
||||
zone: Zone,
|
||||
at: Option<usize>,
|
||||
) -> EntityId {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
let def = &self.library.get(card).def;
|
||||
let entity = Entity {
|
||||
card: def.name.clone(),
|
||||
owner,
|
||||
zone,
|
||||
order_of_play: None,
|
||||
attack: def.attack,
|
||||
health: def.health,
|
||||
pending_destroy: false,
|
||||
};
|
||||
let id = self.entities.len() as EntityId;
|
||||
self.entities.push(entity);
|
||||
self.enter(id, zone, at);
|
||||
id
|
||||
}
|
||||
|
||||
/// Move an entity to the end of `zone` on its owner's side, keeping its id.
|
||||
pub fn move_to(&mut self, id: EntityId, zone: Zone) {
|
||||
let (owner, from) = (self.entity(id).owner, self.entity(id).zone);
|
||||
self.sides[owner as usize]
|
||||
.zone_mut(from)
|
||||
.retain(|&e| e != id);
|
||||
self.enter(id, zone, None);
|
||||
}
|
||||
|
||||
/// The single place an entity arrives in a zone. Entering the board starts a new order of play.
|
||||
fn enter(&mut self, id: EntityId, zone: Zone, at: Option<usize>) {
|
||||
let owner = self.entity(id).owner;
|
||||
let list = self.sides[owner as usize].zone_mut(zone);
|
||||
list.insert(at.map_or(list.len(), |i| i.min(list.len())), id);
|
||||
let entity = &mut self.entities[id as usize];
|
||||
entity.zone = zone;
|
||||
if zone == Zone::Board {
|
||||
self.next_order_of_play += 1;
|
||||
entity.order_of_play = Some(self.next_order_of_play);
|
||||
self.record(Event::Summoned { id });
|
||||
}
|
||||
}
|
||||
|
||||
fn entity(&self, id: EntityId) -> &Entity {
|
||||
self.entities
|
||||
.get(id as usize)
|
||||
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
||||
}
|
||||
|
||||
fn entity_mut(&mut self, id: EntityId) -> &mut Entity {
|
||||
self.entities
|
||||
.get_mut(id as usize)
|
||||
.unwrap_or_else(|| panic!("unknown entity id {id}"))
|
||||
}
|
||||
|
||||
// ---- Queries -------------------------------------------------------
|
||||
|
||||
pub fn card(&self, name: &str) -> &Card {
|
||||
self.library.get(name)
|
||||
}
|
||||
|
||||
pub fn has_card(&self, name: &str) -> bool {
|
||||
self.library.contains(name)
|
||||
}
|
||||
|
||||
/// The cards an Assemble may offer, sorted by name.
|
||||
pub fn pool(&self) -> Vec<&Card> {
|
||||
self.library.pool()
|
||||
}
|
||||
|
||||
pub fn zone_of(&self, id: EntityId) -> Zone {
|
||||
self.entity(id).zone
|
||||
}
|
||||
|
||||
pub fn card_name(&self, id: EntityId) -> &str {
|
||||
&self.entity(id).card
|
||||
}
|
||||
|
||||
pub fn owner_of(&self, id: EntityId) -> PlayerId {
|
||||
self.entity(id).owner
|
||||
}
|
||||
|
||||
/// When the entity last entered the board (larger is later); `None` if it never has.
|
||||
pub fn order_of_play(&self, id: EntityId) -> Option<u32> {
|
||||
self.entity(id).order_of_play
|
||||
}
|
||||
|
||||
/// The ids in one player's zone, in zone order (left to right for the board).
|
||||
pub fn zone(&self, owner: PlayerId, zone: Zone) -> &[EntityId] {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
self.sides[owner as usize].zone(zone)
|
||||
}
|
||||
|
||||
pub fn hero(&self, owner: PlayerId) -> EntityId {
|
||||
assert!(owner < 2, "no player {owner}");
|
||||
self.sides[owner as usize].hero
|
||||
}
|
||||
|
||||
pub fn is_hero(&self, id: EntityId) -> bool {
|
||||
self.sides.iter().any(|s| s.hero == id)
|
||||
}
|
||||
|
||||
/// Every minion on either board, oldest first (order of play).
|
||||
pub fn minions_in_play(&self) -> Vec<EntityId> {
|
||||
let mut ids: Vec<EntityId> = self.sides.iter().flat_map(|s| s.board.clone()).collect();
|
||||
ids.sort_by_key(|&id| self.order_of_play(id));
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn attack(&self, id: EntityId) -> i32 {
|
||||
self.entity(id).attack
|
||||
}
|
||||
|
||||
pub fn health(&self, id: EntityId) -> i32 {
|
||||
self.entity(id).health
|
||||
}
|
||||
|
||||
/// The cost printed on the entity's card.
|
||||
pub fn cost(&self, id: EntityId) -> i32 {
|
||||
self.card(self.card_name(id)).def.cost
|
||||
}
|
||||
|
||||
/// The minions in a player's hand, in hand order.
|
||||
pub fn minions_in_hand(&self, owner: PlayerId) -> Vec<EntityId> {
|
||||
self.zone(owner, Zone::Hand)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| !self.card(self.card_name(id)).is_spell())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next
|
||||
/// Death Creation Step.
|
||||
pub fn is_doomed(&self, id: EntityId) -> bool {
|
||||
let entity = self.entity(id);
|
||||
entity.health <= 0 || entity.pending_destroy
|
||||
}
|
||||
|
||||
/// Index of a minion on its owner's board, left to right; `None` if it is not on the board.
|
||||
pub fn board_index(&self, id: EntityId) -> Option<usize> {
|
||||
self.zone(self.owner_of(id), Zone::Board)
|
||||
.iter()
|
||||
.position(|&e| e == id)
|
||||
}
|
||||
|
||||
pub fn armor(&self, owner: PlayerId) -> i32 {
|
||||
self.sides[owner as usize].armor
|
||||
}
|
||||
|
||||
pub fn active_auras(&self) -> &[ActiveAura] {
|
||||
&self.auras
|
||||
}
|
||||
|
||||
pub fn log(&self) -> &[Event] {
|
||||
&self.log
|
||||
}
|
||||
|
||||
// ---- Changes: each one records its event ----------------------------
|
||||
|
||||
pub fn record(&mut self, event: Event) {
|
||||
self.log.push(event);
|
||||
}
|
||||
|
||||
/// Add a card to the library, or replace the card with the same name.
|
||||
pub fn register(&mut self, def: CardDef) {
|
||||
self.library.register(def);
|
||||
}
|
||||
|
||||
/// Add a card made by Assemble (see `assemble.rs`).
|
||||
pub fn register_assembled(&mut self, def: CardDef) {
|
||||
self.library.register_assembled(def);
|
||||
}
|
||||
|
||||
pub fn set_chooser(&mut self, chooser: Chooser) {
|
||||
self.chooser = chooser;
|
||||
}
|
||||
|
||||
/// Ask the chooser to pick one of `options` (not empty). Panics if it answers
|
||||
/// something that isn't an option.
|
||||
pub fn choose(&mut self, kind: &str, options: &[String]) -> String {
|
||||
let answer = (self.chooser)(kind, options);
|
||||
assert!(
|
||||
options.contains(&answer),
|
||||
"chooser answered {answer:?} for {kind:?}; the options were {options:?}"
|
||||
);
|
||||
answer
|
||||
}
|
||||
|
||||
/// Only an Aura Update calls this.
|
||||
pub fn set_active_auras(&mut self, auras: Vec<ActiveAura>) {
|
||||
self.auras = auras;
|
||||
}
|
||||
|
||||
pub fn clear_log(&mut self) {
|
||||
self.log.clear();
|
||||
}
|
||||
|
||||
pub fn damage(&mut self, target: EntityId, amount: i32) {
|
||||
self.entity_mut(target).health -= amount;
|
||||
self.record(Event::Damaged { target, amount });
|
||||
}
|
||||
|
||||
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
|
||||
/// records `Fatigue` and changes nothing else.
|
||||
pub fn draw(&mut self, player: PlayerId) {
|
||||
match self.zone(player, Zone::Deck).first() {
|
||||
Some(&id) => {
|
||||
self.move_to(id, Zone::Hand);
|
||||
self.record(Event::Drew { player, id });
|
||||
}
|
||||
None => self.record(Event::Fatigue { player }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark for destruction. Nothing leaves play until the Death Creation Step.
|
||||
pub fn mark_destroyed(&mut self, id: EntityId) {
|
||||
self.entity_mut(id).pending_destroy = true;
|
||||
}
|
||||
|
||||
/// Move a minion from play to its owner's graveyard, recording its Death Event.
|
||||
pub fn kill(&mut self, id: EntityId, position: usize) {
|
||||
self.entity_mut(id).pending_destroy = false;
|
||||
self.move_to(id, Zone::Graveyard);
|
||||
self.record(Event::Destroyed { id, position });
|
||||
}
|
||||
|
||||
pub fn gain_armor(&mut self, player: PlayerId, amount: i32) {
|
||||
self.sides[player as usize].armor += amount;
|
||||
self.record(Event::ArmorGained { player, amount });
|
||||
}
|
||||
|
||||
pub fn buff(&mut self, id: EntityId, attack: i32, health: i32) {
|
||||
let entity = self.entity_mut(id);
|
||||
entity.attack += attack;
|
||||
entity.health += health;
|
||||
let (attack, health) = (entity.attack, entity.health);
|
||||
self.record(Event::StatsChanged { id, attack, health });
|
||||
}
|
||||
|
||||
pub fn gain_attack(&mut self, id: EntityId, amount: i32) {
|
||||
let entity = self.entity_mut(id);
|
||||
entity.attack += amount;
|
||||
let (attack, health) = (entity.attack, entity.health);
|
||||
self.record(Event::StatsChanged { id, attack, health });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Test harness: the thin adapter the course tests call. It translates each
|
||||
//! call into engine operations and holds no rules of its own.
|
||||
|
||||
use crate::content;
|
||||
use crate::engine::{self, puzzle, run_sequence, summon, Action, CardLibrary, Event, World};
|
||||
|
||||
pub use crate::engine::{AssembleError, CardDef, Half, Zone};
|
||||
|
||||
pub type Id = u32;
|
||||
pub type Player = u8;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct GameOpts {
|
||||
pub decks: Option<[Vec<String>; 2]>,
|
||||
}
|
||||
|
||||
/// The events the tests read from `log()` (see course/harness.md).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GameEvent {
|
||||
Summoned { id: Id },
|
||||
Damaged { target: Id, amount: i32 },
|
||||
Triggered { source: Id, trigger: String },
|
||||
Destroyed { id: Id },
|
||||
DeathPhase { round: u32 },
|
||||
Drew { player: Player, id: Id },
|
||||
Fatigue { player: Player },
|
||||
ArmorGained { player: Player, amount: i32 },
|
||||
StatsChanged { id: Id, attack: i32, health: i32 },
|
||||
SpellCast { card: String },
|
||||
}
|
||||
|
||||
impl GameEvent {
|
||||
/// The course's view of an engine event; `None` for engine events the course log
|
||||
/// doesn't show.
|
||||
fn from_engine(event: &Event) -> Option<GameEvent> {
|
||||
Some(match event.clone() {
|
||||
Event::Summoned { id } => GameEvent::Summoned { id },
|
||||
Event::Damaged { target, amount } => GameEvent::Damaged { target, amount },
|
||||
Event::Triggered { source, on } => GameEvent::Triggered {
|
||||
source,
|
||||
trigger: on.label().to_string(),
|
||||
},
|
||||
Event::Drew { player, id } => GameEvent::Drew { player, id },
|
||||
Event::Fatigue { player } => GameEvent::Fatigue { player },
|
||||
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
|
||||
Event::StatsChanged { id, attack, health } => {
|
||||
GameEvent::StatsChanged { id, attack, health }
|
||||
}
|
||||
Event::SpellCast { card } => GameEvent::SpellCast { card },
|
||||
Event::Destroyed { id, .. } => GameEvent::Destroyed { id },
|
||||
Event::DeathPhase { round } => GameEvent::DeathPhase { round },
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
Event::Played { .. } => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
world: World,
|
||||
}
|
||||
|
||||
pub fn new_game(opts: GameOpts) -> Game {
|
||||
let mut world = World::new(CardLibrary::new(content::cards()));
|
||||
if let Some(decks) = opts.decks {
|
||||
for (player, deck) in decks.iter().enumerate() {
|
||||
for card in deck {
|
||||
world.create(card, player as Player, Zone::Deck);
|
||||
}
|
||||
}
|
||||
}
|
||||
Game { world }
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Put `card` at the right end of `player`'s board.
|
||||
pub fn summon(&mut self, card: &str, player: Player) -> Id {
|
||||
summon(&mut self.world, card, player, None)
|
||||
}
|
||||
pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id {
|
||||
self.world.create(card, player, Zone::Hand)
|
||||
}
|
||||
pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id {
|
||||
self.world.create(card, player, Zone::Deck)
|
||||
}
|
||||
/// Play a card from hand, as one Sequence: it enters the right end of the board, then
|
||||
/// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.)
|
||||
pub fn play(&mut self, hand_id: Id) {
|
||||
run_sequence(&mut self.world, Action::Play { id: hand_id });
|
||||
}
|
||||
pub fn zone_of(&self, id: Id) -> Zone {
|
||||
self.world.zone_of(id)
|
||||
}
|
||||
/// Board ids, left to right.
|
||||
pub fn board(&self, player: Player) -> Vec<Id> {
|
||||
self.world.zone(player, Zone::Board).to_vec()
|
||||
}
|
||||
pub fn hand(&self, player: Player) -> Vec<Id> {
|
||||
self.world.zone(player, Zone::Hand).to_vec()
|
||||
}
|
||||
pub fn order_of_play(&self, id: Id) -> u32 {
|
||||
self.world
|
||||
.order_of_play(id)
|
||||
.unwrap_or_else(|| panic!("entity {id} has not entered play"))
|
||||
}
|
||||
pub fn name_of(&self, id: Id) -> String {
|
||||
self.world.card_name(id).to_string()
|
||||
}
|
||||
|
||||
// ---- Module 2 ----
|
||||
|
||||
pub fn hero(&self, player: Player) -> Id {
|
||||
self.world.hero(player)
|
||||
}
|
||||
/// Deal damage to one character, as one Sequence.
|
||||
pub fn deal_damage(&mut self, target: Id, amount: i32) {
|
||||
let targets = vec![target];
|
||||
run_sequence(&mut self.world, Action::Damage { targets, amount });
|
||||
}
|
||||
pub fn cast(&mut self, card: &str, player: Player, target: Option<Id>) {
|
||||
let card = card.to_string();
|
||||
run_sequence(
|
||||
&mut self.world,
|
||||
Action::Cast {
|
||||
card,
|
||||
player,
|
||||
target,
|
||||
},
|
||||
);
|
||||
}
|
||||
pub fn health(&self, id: Id) -> i32 {
|
||||
self.world.health(id)
|
||||
}
|
||||
pub fn attack(&self, id: Id) -> i32 {
|
||||
self.world.attack(id)
|
||||
}
|
||||
pub fn armor(&self, player: Player) -> i32 {
|
||||
self.world.armor(player)
|
||||
}
|
||||
pub fn log(&self) -> Vec<GameEvent> {
|
||||
self.world
|
||||
.log()
|
||||
.iter()
|
||||
.filter_map(GameEvent::from_engine)
|
||||
.collect()
|
||||
}
|
||||
pub fn clear_log(&mut self) {
|
||||
self.world.clear_log();
|
||||
}
|
||||
|
||||
// ---- Module 3 ----
|
||||
|
||||
/// Mark `target` pending destroy, as one Sequence. It leaves play in the death step.
|
||||
pub fn destroy(&mut self, target: Id) {
|
||||
let targets = vec![target];
|
||||
run_sequence(&mut self.world, Action::Destroy { targets });
|
||||
}
|
||||
|
||||
// ---- Module 5 ----
|
||||
|
||||
/// Add card definitions to this game, replacing any with the same name.
|
||||
pub fn register_cards(&mut self, defs: Vec<CardDef>) {
|
||||
for def in defs {
|
||||
self.world.register(def);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `base` with `half` of its text replaced by the same half of `donor`,
|
||||
/// and return the new card's name, `"<base> (Assembled)"`.
|
||||
pub fn assemble(
|
||||
&mut self,
|
||||
base: &str,
|
||||
donor: &str,
|
||||
half: Half,
|
||||
) -> Result<String, AssembleError> {
|
||||
let base = &self.world.card(base).def;
|
||||
let donor = &self.world.card(donor).def;
|
||||
let def = engine::assemble(base, donor, half)?;
|
||||
Ok(engine::register_assembled(&mut self.world, def))
|
||||
}
|
||||
|
||||
pub fn cost(&self, id: Id) -> i32 {
|
||||
self.world.cost(id)
|
||||
}
|
||||
|
||||
/// Who makes choices (Assemble's base, donor and half): called with the kind of
|
||||
/// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them.
|
||||
pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) {
|
||||
self.world.set_chooser(Box::new(chooser));
|
||||
}
|
||||
}
|
||||
|
||||
// ======== Part II: Baba Is You ========
|
||||
|
||||
pub use crate::engine::puzzle::{Dir, 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()),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod content;
|
||||
pub mod engine;
|
||||
pub mod harness;
|
||||
@@ -0,0 +1 @@
|
||||
{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "08" }
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,17 @@
|
||||
# Module 8 reference engine (TypeScript)
|
||||
|
||||
The course's own engine as of the end of Module 8. Part I's card engine is unchanged, and all of its tests still pass. The Part II engine in `src/engine/puzzle/` now moves objects: each `YOU` object's move is planned in full, push chain and all, and only then committed. Module 7's reference was the starting point, so a diff between the two shows what this module adds.
|
||||
|
||||
- `cards/` (at the package root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it.
|
||||
- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT`, the property `PUSH` of the base rule `TEXT IS PUSH`, and from Module 8 the property `YOU`, which picks the objects that move.
|
||||
- `grid.ts`: the level format, object kinds and the board. Module 8 adds `Board.find`, `Board.remove`, `Board.shift`, `Board.at` and `Kind.format` (the kind as placed, for `kindsAt`).
|
||||
- `movement.ts` (new): validate, then commit. `Planner` (`tryMove`, `stayingAt`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step.
|
||||
- `level.ts`: a level in play. Module 8 adds `Level.moveYou`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed; the rules are reparsed once at the end. `hasProperty` is `false` for a destroyed object.
|
||||
- `word.ts`, `parse.ts`, `resolve.ts`: unchanged.
|
||||
- `index.ts`: the module's exports.
|
||||
- The rest of `src/engine/`: Part I, unchanged.
|
||||
- `src/content/movement.ts` (new) and `src/content/index.ts`: the movement responses, in the order the engine runs them: `OPEN`/`SHUT`, `PUSH`, `STOP`. What a property does when something moves is content, not engine.
|
||||
- `src/content/words.json`: Module 8 adds `OPEN` and `SHUT`.
|
||||
- `src/harness.ts`: the adapter the course tests call. Module 8 adds `move`, `posOf` and `kindsAt` on `Baba`.
|
||||
|
||||
Run it with the module's tests: `node tools/verify-references.mjs 08` from the course repository.
|
||||
+1496
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "systemic-design-workspace",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// <reference types="vite/client" />
|
||||
// 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.
|
||||
//
|
||||
// `import.meta.glob` is Vite's (Vitest runs on Vite): it imports every matching
|
||||
// JSON file when this module loads, so no file system code is needed.
|
||||
|
||||
import type { CardDef, puzzle } from "../engine";
|
||||
import wordList from "./words.json";
|
||||
|
||||
export { responses as movement } from "./movement";
|
||||
|
||||
const files = import.meta.glob<CardDef>("../../cards/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** Every card in `cards/`, in file-name order. */
|
||||
export function cards(): CardDef[] {
|
||||
return Object.keys(files)
|
||||
.sort()
|
||||
.map((path) => files[path]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
@@ -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,17 @@
|
||||
[
|
||||
{ "name": "BABA", "role": "noun" },
|
||||
{ "name": "ROCK", "role": "noun" },
|
||||
{ "name": "WALL", "role": "noun" },
|
||||
{ "name": "FLAG", "role": "noun" },
|
||||
{ "name": "LAVA", "role": "noun" },
|
||||
{ "name": "KEY", "role": "noun" },
|
||||
{ "name": "DOOR", "role": "noun" },
|
||||
{ "name": "SKULL", "role": "noun" },
|
||||
{ "name": "WATER", "role": "noun" },
|
||||
{ "name": "YOU", "role": "property" },
|
||||
{ "name": "WIN", "role": "property" },
|
||||
{ "name": "PUSH", "role": "property" },
|
||||
{ "name": "STOP", "role": "property" },
|
||||
{ "name": "OPEN", "role": "property" },
|
||||
{ "name": "SHUT", "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,42 @@
|
||||
// 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.
|
||||
|
||||
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. */
|
||||
export function auraUpdateOther(world: World): void {
|
||||
world.setActiveAuras(
|
||||
world.minionsInPlay().flatMap((id) =>
|
||||
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), 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.
|
||||
*/
|
||||
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
|
||||
let max = 1;
|
||||
let extra = 0;
|
||||
for (const active of world.activeAuras()) {
|
||||
const { trigger, times, stacking } = active.aura;
|
||||
if (active.owner !== owner || trigger !== on) continue;
|
||||
if (stacking === "max") max = Math.max(max, times);
|
||||
else extra += times - 1;
|
||||
}
|
||||
return max + extra;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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" };
|
||||
|
||||
/** 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":
|
||||
throw new Error("spawnText needs a grid: it is a Part III effect");
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,22 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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.
|
||||
|
||||
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 { Ctx, runSequence, summon, type Action } from "./queue";
|
||||
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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,22 @@
|
||||
// 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.
|
||||
|
||||
export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
|
||||
export { Level, YOU } 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 { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word";
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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.
|
||||
|
||||
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
|
||||
import { delta, Planner, type Dir, type Plan, type Response } from "./movement";
|
||||
import { parse, type Rule } from "./parse";
|
||||
import { resolve } from "./resolve";
|
||||
import { Lexicon, type WordDef } from "./word";
|
||||
|
||||
/** The property that makes an object move when the player moves. */
|
||||
export const YOU = "YOU";
|
||||
|
||||
export class Level {
|
||||
readonly board: Board;
|
||||
private readonly lexicon: Lexicon;
|
||||
private compiled: Rule[] = [];
|
||||
private active: Rule[] = [];
|
||||
|
||||
/**
|
||||
* A level from its definition, the words content provides, and content's
|
||||
* movement responses, in the order they are to run.
|
||||
*/
|
||||
constructor(
|
||||
def: LevelDef,
|
||||
words: readonly WordDef[],
|
||||
private readonly moveResponses: readonly Response[],
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every `YOU` object one tile in `dir`, then reparse. 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.
|
||||
*/
|
||||
moveYou(dir: Dir): 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);
|
||||
}
|
||||
this.reparse();
|
||||
}
|
||||
|
||||
private commit(plan: Plan, dir: Dir): void {
|
||||
for (const id of plan.destroyed) this.board.remove(id);
|
||||
for (const id of plan.moving) if (!plan.destroyed.has(id)) this.board.shift(id, delta(dir));
|
||||
}
|
||||
|
||||
private 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,96 @@
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,227 @@
|
||||
// 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.
|
||||
|
||||
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 };
|
||||
|
||||
/** 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; 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[] {
|
||||
const deaths = deathCreationStep(world);
|
||||
// After the step: a minion that just died no longer contributes its auras
|
||||
// to the Death Phase that resolves these deaths.
|
||||
auraUpdateOther(world);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,301 @@
|
||||
// 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";
|
||||
|
||||
/** 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];
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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,158 @@
|
||||
// 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, movement, words } from "./content";
|
||||
import {
|
||||
assemble,
|
||||
CardLibrary,
|
||||
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;
|
||||
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, 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
|
||||
}
|
||||
|
||||
/** 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());
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in new issue
Block a user