From 68fe9834c3906e4f378568db3ac2a278e54f0214 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 03:33:53 +0100 Subject: [PATCH] feat: module 06 parser First Part II module. The cumulative reference adds a second engine (src/engine/puzzle: grid, lexer, parser) beside Part I's; nouns and properties are data in src/content/words.json, and the engine names only IS/AND/NOT/TEXT and the base rule TEXT IS PUSH. Rules are recompiled from the board on every change; rules() is a sorted set. Adds the M6 scenarios and tests (rows, columns, shared IS, AND distribution, stacked text, base rule), the HOT word as a data-only Expansion Drop, the lesson with the Part II bridge, and the Rust LevelDef shape in harness.md. Co-Authored-By: Claude Opus 5.5 --- course/06-parser/expansion/README.md | 15 + .../06-parser/expansion/solution/words.json | 16 + .../expansion/tests/rust/test_06x.rs | 19 + .../expansion/tests/ts/test_06x.test.ts | 18 + course/06-parser/lesson.md | 344 ++++ course/06-parser/reference/rust/.course.json | 1 + course/06-parser/reference/rust/.gitignore | 1 + course/06-parser/reference/rust/Cargo.toml | 9 + course/06-parser/reference/rust/README.md | 16 + .../reference/rust/src/content/mod.rs | 48 + .../reference/rust/src/content/words.json | 15 + .../reference/rust/src/engine/assemble.rs | 153 ++ .../reference/rust/src/engine/aura.rs | 53 + .../reference/rust/src/engine/card.rs | 290 ++++ .../reference/rust/src/engine/death.rs | 30 + .../reference/rust/src/engine/effect.rs | 91 + .../reference/rust/src/engine/event.rs | 51 + .../reference/rust/src/engine/mod.rs | 43 + .../reference/rust/src/engine/puzzle/grid.rs | 96 ++ .../reference/rust/src/engine/puzzle/level.rs | 47 + .../reference/rust/src/engine/puzzle/mod.rs | 17 + .../reference/rust/src/engine/puzzle/parse.rs | 95 ++ .../reference/rust/src/engine/puzzle/word.rs | 84 + .../reference/rust/src/engine/queue.rs | 285 ++++ .../reference/rust/src/engine/world.rs | 378 +++++ .../06-parser/reference/rust/src/harness.rs | 223 +++ course/06-parser/reference/rust/src/lib.rs | 3 + course/06-parser/reference/ts/.course.json | 1 + course/06-parser/reference/ts/.gitignore | 1 + course/06-parser/reference/ts/README.md | 16 + .../06-parser/reference/ts/package-lock.json | 1496 +++++++++++++++++ course/06-parser/reference/ts/package.json | 14 + .../reference/ts/src/content/index.ts | 32 + .../reference/ts/src/content/words.json | 15 + .../reference/ts/src/engine/assemble.ts | 102 ++ .../06-parser/reference/ts/src/engine/aura.ts | 42 + .../06-parser/reference/ts/src/engine/card.ts | 175 ++ .../reference/ts/src/engine/death.ts | 21 + .../reference/ts/src/engine/effect.ts | 68 + .../reference/ts/src/engine/event.ts | 22 + .../reference/ts/src/engine/index.ts | 43 + .../reference/ts/src/engine/puzzle/grid.ts | 67 + .../reference/ts/src/engine/puzzle/index.ts | 12 + .../reference/ts/src/engine/puzzle/level.ts | 34 + .../reference/ts/src/engine/puzzle/parse.ts | 73 + .../reference/ts/src/engine/puzzle/word.ts | 65 + .../reference/ts/src/engine/queue.ts | 227 +++ .../reference/ts/src/engine/world.ts | 301 ++++ course/06-parser/reference/ts/src/harness.ts | 136 ++ course/06-parser/reference/ts/tsconfig.json | 13 + .../06-parser/reference/ts/vitest.config.ts | 5 + course/06-parser/scenarios.md | 39 + course/06-parser/tests/rust/test_06.rs | 118 ++ course/06-parser/tests/ts/test_06.test.ts | 78 + course/harness.md | 23 +- 55 files changed, 5679 insertions(+), 1 deletion(-) create mode 100644 course/06-parser/expansion/README.md create mode 100644 course/06-parser/expansion/solution/words.json create mode 100644 course/06-parser/expansion/tests/rust/test_06x.rs create mode 100644 course/06-parser/expansion/tests/ts/test_06x.test.ts create mode 100644 course/06-parser/lesson.md create mode 100644 course/06-parser/reference/rust/.course.json create mode 100644 course/06-parser/reference/rust/.gitignore create mode 100644 course/06-parser/reference/rust/Cargo.toml create mode 100644 course/06-parser/reference/rust/README.md create mode 100644 course/06-parser/reference/rust/src/content/mod.rs create mode 100644 course/06-parser/reference/rust/src/content/words.json create mode 100644 course/06-parser/reference/rust/src/engine/assemble.rs create mode 100644 course/06-parser/reference/rust/src/engine/aura.rs create mode 100644 course/06-parser/reference/rust/src/engine/card.rs create mode 100644 course/06-parser/reference/rust/src/engine/death.rs create mode 100644 course/06-parser/reference/rust/src/engine/effect.rs create mode 100644 course/06-parser/reference/rust/src/engine/event.rs create mode 100644 course/06-parser/reference/rust/src/engine/mod.rs create mode 100644 course/06-parser/reference/rust/src/engine/puzzle/grid.rs create mode 100644 course/06-parser/reference/rust/src/engine/puzzle/level.rs create mode 100644 course/06-parser/reference/rust/src/engine/puzzle/mod.rs create mode 100644 course/06-parser/reference/rust/src/engine/puzzle/parse.rs create mode 100644 course/06-parser/reference/rust/src/engine/puzzle/word.rs create mode 100644 course/06-parser/reference/rust/src/engine/queue.rs create mode 100644 course/06-parser/reference/rust/src/engine/world.rs create mode 100644 course/06-parser/reference/rust/src/harness.rs create mode 100644 course/06-parser/reference/rust/src/lib.rs create mode 100644 course/06-parser/reference/ts/.course.json create mode 100644 course/06-parser/reference/ts/.gitignore create mode 100644 course/06-parser/reference/ts/README.md create mode 100644 course/06-parser/reference/ts/package-lock.json create mode 100644 course/06-parser/reference/ts/package.json create mode 100644 course/06-parser/reference/ts/src/content/index.ts create mode 100644 course/06-parser/reference/ts/src/content/words.json create mode 100644 course/06-parser/reference/ts/src/engine/assemble.ts create mode 100644 course/06-parser/reference/ts/src/engine/aura.ts create mode 100644 course/06-parser/reference/ts/src/engine/card.ts create mode 100644 course/06-parser/reference/ts/src/engine/death.ts create mode 100644 course/06-parser/reference/ts/src/engine/effect.ts create mode 100644 course/06-parser/reference/ts/src/engine/event.ts create mode 100644 course/06-parser/reference/ts/src/engine/index.ts create mode 100644 course/06-parser/reference/ts/src/engine/puzzle/grid.ts create mode 100644 course/06-parser/reference/ts/src/engine/puzzle/index.ts create mode 100644 course/06-parser/reference/ts/src/engine/puzzle/level.ts create mode 100644 course/06-parser/reference/ts/src/engine/puzzle/parse.ts create mode 100644 course/06-parser/reference/ts/src/engine/puzzle/word.ts create mode 100644 course/06-parser/reference/ts/src/engine/queue.ts create mode 100644 course/06-parser/reference/ts/src/engine/world.ts create mode 100644 course/06-parser/reference/ts/src/harness.ts create mode 100644 course/06-parser/reference/ts/tsconfig.json create mode 100644 course/06-parser/reference/ts/vitest.config.ts create mode 100644 course/06-parser/scenarios.md create mode 100644 course/06-parser/tests/rust/test_06.rs create mode 100644 course/06-parser/tests/ts/test_06.test.ts diff --git a/course/06-parser/expansion/README.md b/course/06-parser/expansion/README.md new file mode 100644 index 0000000..2763c07 --- /dev/null +++ b/course/06-parser/expansion/README.md @@ -0,0 +1,15 @@ +# Module 6 Expansion Drop: HOT + +The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check. + +- Word: HOT, a property. "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." (Baba Is You Wiki, HOT page, recorded in `docs/research/baba-is-you.md` §5.) This module only parses it; what HOT does arrives with the turn in Module 9. +- Tests: `tests/rust/test_06x.rs` and `tests/ts/test_06x.test.ts`. Copy the one for your track into your workspace's `tests/`. +- Reference answer: `solution/words.json`, one file for both tracks: the Module 6 reference's `src/content/words.json` with one entry added, `{ "name": "HOT", "role": "property" }`. Copy it over your workspace's `src/content/words.json`. Nothing under `src/engine/` changes, and neither does the harness. +- Check: commit your workspace before you start, then run `node tools/expansion-check.mjs ` from the course repository. It fails if any file under `src/engine/` changed. + +## Scenario + +### test_06x_hot_word + +- Setup: `newBaba` with an empty level 5 wide and 1 high. Place `LAVA (0,0) IS (1,0) HOT (2,0) AND (3,0) PUSH (4,0)`. +- Assertions: `rules()` is exactly `["LAVA IS HOT", "LAVA IS PUSH", "TEXT IS PUSH"]`. Before the drop the parser doesn't know `HOT`, so the sentence stops after `IS` and spells nothing; `LAVA IS PUSH` appears only once `HOT AND` is a valid run of complements. diff --git a/course/06-parser/expansion/solution/words.json b/course/06-parser/expansion/solution/words.json new file mode 100644 index 0000000..0876b3b --- /dev/null +++ b/course/06-parser/expansion/solution/words.json @@ -0,0 +1,16 @@ +[ + { "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": "HOT", "role": "property" } +] diff --git a/course/06-parser/expansion/tests/rust/test_06x.rs b/course/06-parser/expansion/tests/rust/test_06x.rs new file mode 100644 index 0000000..64532fe --- /dev/null +++ b/course/06-parser/expansion/tests/rust/test_06x.rs @@ -0,0 +1,19 @@ +// Module 6 Expansion Drop: HOT. The scenario is in expansion/README.md. +use engine::harness::*; + +#[test] +fn test_06x_hot_word() { + let mut b = new_baba(LevelDef { + width: 5, + height: 1, + ..Default::default() + }); + for (word, x) in [("LAVA", 0), ("IS", 1), ("HOT", 2), ("AND", 3), ("PUSH", 4)] { + b.place(&format!("text:{word}"), x, 0); + } + + assert_eq!( + b.rules(), + vec!["LAVA IS HOT", "LAVA IS PUSH", "TEXT IS PUSH"] + ); +} diff --git a/course/06-parser/expansion/tests/ts/test_06x.test.ts b/course/06-parser/expansion/tests/ts/test_06x.test.ts new file mode 100644 index 0000000..3d3ce26 --- /dev/null +++ b/course/06-parser/expansion/tests/ts/test_06x.test.ts @@ -0,0 +1,18 @@ +// Module 6 Expansion Drop: HOT. The scenario is in expansion/README.md. +import { expect, test } from "vitest"; +import { newBaba } from "../src/harness"; + +test("test_06x_hot_word", () => { + const b = newBaba({ width: 5, height: 1, objects: [] }); + for (const [word, x] of [ + ["LAVA", 0], + ["IS", 1], + ["HOT", 2], + ["AND", 3], + ["PUSH", 4], + ] as const) { + b.place(`text:${word}`, x, 0); + } + + expect(b.rules()).toEqual(["LAVA IS HOT", "LAVA IS PUSH", "TEXT IS PUSH"]); +}); diff --git a/course/06-parser/lesson.md b/course/06-parser/lesson.md new file mode 100644 index 0000000..3d17577 --- /dev/null +++ b/course/06-parser/lesson.md @@ -0,0 +1,344 @@ +# Module 6: Grid, lexer and parser + +This is the first module of Part II. In Part I the rules lived inside the cards: Harvest Golem's Deathrattle was a clause on Harvest Golem, and your engine's job was to find the right clause at the right moment and run it. Part II turns that around. In Baba Is You the objects on the board have no behaviour at all. A rock is just a rock until some text on the board says `ROCK IS PUSH`. Every rule comes from words lying on the grid, and the words can be pushed around, so the rules change while you play. The engine's job is now to *compile* the rules from the board, and to compile them again every time the board changes. + +This module builds the compiler's front end: a grid that holds objects and text, a lexer that turns text into words with grammatical roles, and a parser that turns words into rules. Nothing moves yet (that is Module 8), and no rule does anything yet (Modules 7 and 9). By the end, `rules()` reads any board you build. + +You need your Module 5 workspace with all Module 1–5 tests passing, under git. Part I's engine stays where it is, and its tests keep running: Part II is a second engine next to the first, in the same project. Part III will plug them together. + +## Observe + +All quotes in this module come from sources recorded in the course's research notes. The Baba Is You Wiki is an unofficial community wiki, and the course treats it as the best available description of the game, not as Hempuli's own word. + +How a sentence reads, from the wiki's [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: + +> Rules read from left to right in a row, or from top to bottom in a column. + +A noun can take a property or another noun ([Category:Nouns](https://babaiswiki.fandom.com/wiki/Category:Nouns)): + +> A noun can be used as a NOUN RULE PROPERTY statement e.g. BABA IS YOU to give it a property or as a NOUN IS NOUN statement eg. WALL IS WATER to turn an object into another object. + +Sentences can cross. The [Esolangs wiki](https://esolangs.org/wiki/Baba_Is_You) says "multiple rules are able to cross and overlap if the parsing rules allow for a valid statement", so one `IS` can sit in a row sentence and a column sentence at once. + +`AND` joins words on either side of `IS`. The [AND](https://babaiswiki.fandom.com/wiki/AND) page calls it "mechanically equivalent to each rule being defined individually", and gives this table row: + +> BABA AND KEKE IS PUSH AND STOP = BABA IS PUSH, BABA IS STOP, KEKE IS PUSH, KEKE IS STOP + +Text can share a tile. The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: "If more than one parsable object is stacked, the game will attempt to produce all possible rules." The game's designer, Arvi "Hempuli" Teikari, told Game Developer that this was one of the hard parts: "the stacking adds the need to go through all the possible permutations a rule could have" ([Designing Baba Is You's delightfully innovative rule-writing system](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)). + +Some rules have no text at all. Again from [Rule](https://babaiswiki.fandom.com/wiki/Rule): + +> The following rules are enabled by default, even though there isn't any text saying so. TEXT IS PUSH, LEVEL IS STOP, CURSOR IS SELECT. These rules can still be detected by FEELING and MIMIC and negated by NOT and BROKEN. These rules are not listed in the pause menu. + +That is why you can push text around although no sentence says so. `TEXT` is a noun about text itself ([TEXT](https://babaiswiki.fandom.com/wiki/TEXT)). + +And when the rules are worked out. The [Rule](https://babaiswiki.fandom.com/wiki/Rule) page: "Rules parse when any parsable object changes position or is destroyed, or an object is transformed to text. Between parsings, active rules are simply stored." Teikari describes the same thing from the inside (same Game Developer article): "The game only updates the rules if it needs to", and when it does, "The previous rules are removed entirely, and words that could potentially start rule sentences are detected on the level." + +Read that last quote twice. The game doesn't edit its rule list when a word moves. It throws the list away and reads the board again. + +## Predict + +Write your answers down before you open the discussion, for example in `notes/06.md` in your workspace. Coordinates are (x, y), with x growing right and y growing down. + +1. Text `BABA` (0,1), `IS` (1,1), `YOU` (2,1), `FLAG` (1,0), `WIN` (1,2). Draw it. Which rules does it spell? +2. One row: `BABA AND ROCK IS PUSH AND STOP`. How many rules? Which? +3. Text `BABA` and text `ROCK` both on (0,0), then `IS` (1,0), `YOU` (2,0). Which rules? +4. One row: `YOU IS FLAG`. Which rules? And a level with no text at all? +5. Your Module 5 engine stored each card's behaviour on the card. Where would you store "Baba is YOU" here: on each Baba object, on the noun `BABA`, somewhere else? What has to happen when the `YOU` text is pushed out of line? +6. Your engine has to know that `IS` is special. Does it have to know that `BABA` is a noun? + +
Discussion + +1. `BABA IS YOU` across the middle row and `FLAG IS WIN` down the middle column, sharing the `IS`. Plus `TEXT IS PUSH`, which is always there. + +2. Four: `BABA IS PUSH`, `BABA IS STOP`, `ROCK IS PUSH`, `ROCK IS STOP`. AND distributes over both sides: two subjects times two complements. + +3. `BABA IS YOU` and `ROCK IS YOU`. Each stacked word gets its turn as the start of the sentence: "all possible rules". + +4. None from the text: a sentence must start with a noun, and `YOU` is a property. The parser never reads right to left, so it doesn't find `FLAG IS YOU` either. With no text at all, the rules are exactly `TEXT IS PUSH`. + +5. Nowhere permanent. If "Baba is YOU" were stored on each Baba, pushing `YOU` away would have to find every Baba and take it back, and a Baba created later would have to look up what it missed. The rule belongs to a table derived from the board. Objects ask the table. When the board changes, the table is rebuilt: the game "removes the previous rules entirely". + +6. It shouldn't. `IS`, `AND`, `NOT` are grammar, and the engine must know them to parse at all. `BABA`, `ROCK` and `FLAG` are vocabulary: the parser only needs to know each one's *role* (noun or property), and that can come from data. That is the Zero-Coupling Law of Part I, applied to words: the engine doesn't name nouns. + +
+ +## Spec + +From the course repository, copy the module's tests into your workspace: + +```sh +node tools/start-module.mjs 06 ~/my-engine +``` + +### The level format + +A level is data, in the format of `course/schema/level.schema.json`: a size and a list of objects. + +```json +{ + "width": 3, + "height": 3, + "objects": [ + { "kind": "text:BABA", "x": 0, "y": 1 }, + { "kind": "text:IS", "x": 1, "y": 1 }, + { "kind": "text:YOU", "x": 2, "y": 1 }, + { "kind": "baba", "x": 1, "y": 2 } + ] +} +``` + +- A kind is either an object, named by its noun in lower case (`"baba"`), or a piece of text carrying one word (`"text:BABA"`). Text objects are objects like any other; they just happen to be readable. +- `x` grows right and `y` grows down. Any number of objects may share a tile. +- The tests build most levels with `place` rather than from files. + +### The harness + +Part II has its own harness object, `Baba`, next to Part I's `Game`: + +| TypeScript | Rust | +|---|---| +| `newBaba(level: LevelDef): Baba` | `new_baba(level: LevelDef) -> Baba` (free function) | +| `place(kind, x, y): Id` | `place(&mut self, kind: &str, x: i32, y: i32) -> Id` | +| `rules(): string[]` | `rules(&self) -> Vec` | + +- `newBaba` builds the level and parses its text straight away. +- `place` adds one object and returns its id. +- `rules()` returns every distinct rule in force as a normalised string (`"BABA IS YOU"`), sorted. It always includes `"TEXT IS PUSH"`. A rule spelled twice on the board appears once. In the real game a stacked noun can make a conversion rule parse more than once, and that duplicates objects (the wiki's [Advanced rulebook](https://babaiswiki.fandom.com/wiki/Advanced_rulebook)), so copies are not nothing; but `rules()` is a set, and no test counts copies. +- In Rust, `LevelDef` and `LevelObject` are structs in (or re-exported from) `src/harness.rs`, and `LevelDef` must implement `Default`: the tests write `LevelDef { width: 3, height: 3, ..Default::default() }`. The exact shape is in `course/harness.md`. + +### The tests + +The full statements are in `course/06-parser/scenarios.md`. + +- `test_06_horizontal_rule`: a level file with `BABA IS YOU` on the top row, a `baba` object, and `YOU IS FLAG` on the bottom row. Only `BABA IS YOU` (and `TEXT IS PUSH`). +- `test_06_crossword_shared_is`: Predict question 1, built one word at a time. Before `WIN` arrives, `FLAG` above `IS` is half a sentence; after, the column spells `FLAG IS WIN`. The rules are recompiled when the board changes. +- `test_06_and_distributes`: `BABA AND ROCK IS PUSH AND STOP`, four rules. +- `test_06_stacked_text_all_combinations`: `BABA` and `ROCK` stacked, then `IS YOU`. Both rules. +- `test_06_default_text_is_push`: a level with one `baba` and no text. Exactly `TEXT IS PUSH`. + +The crossword test: + +```rust +#[test] +fn test_06_crossword_shared_is() { + let mut b = new_baba(empty_level(3, 3)); + write( + &mut b, + &[("BABA", 0, 1), ("IS", 1, 1), ("YOU", 2, 1), ("FLAG", 1, 0)], + ); + + assert_eq!(b.rules(), vec!["BABA IS YOU", "TEXT IS PUSH"]); + + b.place("text:WIN", 1, 2); + + assert_eq!( + b.rules(), + vec!["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"] + ); +} +``` + +```ts +test("test_06_crossword_shared_is", () => { + const b = newBaba(emptyLevel(3, 3)); + write(b, [ + ["BABA", 0, 1], + ["IS", 1, 1], + ["YOU", 2, 1], + ["FLAG", 1, 0], + ]); + + expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH"]); + + b.place("text:WIN", 1, 2); + + expect(b.rules()).toEqual(["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"]); +}); +``` + +`write` places one `text:` object per entry; `empty_level`/`emptyLevel` is a level with no objects. + +Run `cargo test` or `npm test`. The Module 6 tests fail to compile (Rust) or fail (TypeScript): there is no `newBaba`. Modules 1–5 still pass. That is RED. + +## Build + +Make the five tests pass, and keep Modules 1–5 green. Where code goes: + +- **A second engine, beside the first.** Put Part II in its own directory under `src/engine/`. It shares nothing with Part I's world yet. Pick its name with care: the coupling audit reads every spelling of every noun in the catalog, so a module called `baba` names `BABA`. The reference calls it `puzzle`. +- **Grammar in the engine, vocabulary in content.** The engine may name the operators `IS`, `AND` and `NOT`, the meta-noun `TEXT`, and properties such as `PUSH` (the base rule needs it). It must not name a noun. The nouns, and the properties the parser accepts, are a word list in `src/content/`: data, one entry per word with its role. The reference uses a JSON file, `src/content/words.json`, the same file in both tracks. +- **Three stages.** The *grid* stores objects by id with a tile each. The *lexer* reads the text objects and, with the word list, turns them into words with a role, grouped by tile. Text whose word isn't in the list is left out, so it spells nothing. The *parser* turns words into rules. +- **Compile, don't patch.** Store the rule list next to the board. Every `place` (and, from Module 8, every move) throws it away and parses the whole board again. Levels are small; a full reparse is cheap and can't drift out of date. +- `src/harness` stays thin: `newBaba` builds the level with your content's words, `place` forwards, `rules` formats, sorts and removes duplicates. + +The grammar for this module: + +```text +sentence = subjects IS complements +subjects = NOUN { AND NOUN } +complements = (NOUN | PROPERTY) { AND (NOUN | PROPERTY) } +``` + +read left to right in a row and top to bottom in a column. `NOT` is a known operator but not part of the grammar yet (Module 7): a `NOT` breaks a sentence, as an unknown word does. + +
Hint 1 — nudge + +Where does a sentence *begin*? You could try every noun on the board as a start and read right and down. That works, but `BABA AND ROCK IS PUSH` then reads twice: once from `BABA` and once from `ROCK`. Every sentence has exactly one `IS` in it. What if you start there and read outwards? + +
+ +
Hint 2 — concept + +- **Start at every `IS`, twice**: once in its row, once in its column. Each `IS` is in at most one row sentence and one column sentence, so nothing is read twice. +- **Read a run outwards.** Before the `IS`, walking backwards, collect the nouns on the next tile; if the tile after them holds an `AND`, keep going from the tile after that. After the `IS`, the same, forwards, with nouns and properties. If the first tile has nothing that fits, the run is empty and the sentence spells nothing. +- **Stacking is free.** A tile is a list of words. "The nouns on this tile" is a list too, and every one of them goes into the run. Two stacked subjects and one complement are two rules, with no special case. +- **One rule per pair.** Subjects × complements. That is AND distributing. +- **Base rules first.** Start the list with `TEXT IS PUSH`, then add what the text spells. +- **Keep the text ids.** A rule can remember the three text objects that spelled it. Two copies of `BABA IS YOU` spelled by different text are two rules; the same text found from two directions can't happen when you start from `IS`. + +
+ +
Hint 3 — pseudo-code + +```text +lex(board, words): + tiles = map (x, y) -> list of {id, word, role} + for each text object o, oldest first: + role = words.role(o.word) // IS AND NOT: operator, TEXT: noun, else from content + if role is known: tiles[o.x, o.y].push({o.id, o.word, role}) + +parse(board, words): + tiles = lex(board, words) + rules = [TEXT IS PUSH] + for each IS token t at (x, y): + for (dx, dy) in [(1, 0), (0, 1)]: + subjects = run(tiles, (x - dx, y - dy), (-dx, -dy), noun) + complements = run(tiles, (x + dx, y + dy), (dx, dy), noun or property) + for s in subjects, c in complements: + rules.push({s.word, c.word, text: [s.id, t.id, c.id]}) + return rules + +run(tiles, at, step, fits): + found = [w in tiles[at] where fits(w)] + if found is empty: return [] + if tiles[at + step] has an AND: + found += run(tiles, at + 2 * step, step, fits) + return found +``` + +
+ +When your tests pass, compare with the reference engine in `course/06-parser/reference/rust/src/engine/puzzle/` or `course/06-parser/reference/ts/src/engine/puzzle/`: `grid` (the level format and the board), `word` (lexicon and lexer), `parse` and `level` (the board plus its compiled rules). The words are in `src/content/words.json`. + +Decisions in the reference that the tests don't force: + +- **`rules()` deduplicates; the engine doesn't.** The engine's list keeps a rule once per set of text objects that spells it, because stacked text really does produce duplicate rules (see Spec). The harness turns that list into a sorted set of strings. +- **Unknown words spell nothing.** Text whose word isn't in the lexicon is skipped by the lexer, so it breaks any sentence it stands in. That is what makes the Expansion Drop below a data change. +- **Content can't redefine grammar.** The lexicon starts from content's word list and then sets `IS`, `AND`, `NOT` and `TEXT` itself. +- **A kind is its noun in lower case.** `"baba"` is a thing of noun `BABA`. The engine never checks that the noun exists: an object of an unknown noun is just an object no rule can name. +- **Off the board is an error.** `place` outside the level's width and height panics (Rust) or throws (TypeScript). +- **Reparse on every change**, including placing a non-text object. The real game only reparses when text moves (Observe), which gives the same rules; the reference trades a little speed for one fewer condition. + +> **How the real game differs** +> - The real game has more grammar: conditions such as `ON`, `NEAR` and `FACING`, which the wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) page shows reparsing between movement stages ("conditions (e.g. IT ON BABA IS YOU AND MOVE) reparse in between"), verbs other than `IS`, such as `HAS` and `WRITE` (same page), and `NOT` (Module 7). The course's grammar is `NOUN [AND NOUN]* IS (NOUN|PROPERTY) [AND (NOUN|PROPERTY)]*`. +> - Where two sentences overlap on an `AND`, the real game does something subtler: "Overlapping rules with AND in the overlapped part does not apply AND to the right/downward rule" ([AND](https://babaiswiki.fandom.com/wiki/AND)). The course's parser applies AND in both, and no test covers the case. +> - The real game has two more base rules, `LEVEL IS STOP` and `CURSOR IS SELECT` ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). The course has no level object and no cursor. +> - Base rules "are not listed in the pause menu" (same page). The course's `rules()` lists `TEXT IS PUSH`, because Module 7 negates it and the tests need to see it. +> - The real game reparses only when text has moved, been destroyed or been created (same page). The reference reparses on every change to the board. + +## Name it + +Two names, one old and one for this course. + +The old one is from compilers: a **lexer** and a **parser**. The lexer turns a stream of characters into tokens with a category; the parser turns tokens into structure according to a grammar. Here the "characters" are text objects on a 2D grid, the categories are noun, property and operator, and the structure is a list of rules. The split pays off at once: the parser never looks at an object, only at tokens with a role, and the lexer is the only place that consults the word list. Adding a word touches neither. + +The new one is the theme of Part II: **rules are compiled from world state.** In Part I, a rule was something a card *had*. Here a rule is something the board *says*, and the engine's rule table is a function of the board: change the board, recompute the table. Teikari's description of the real game is exactly this: "The previous rules are removed entirely, and words that could potentially start rule sentences are detected on the level" ([Game Developer](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system)). Nothing in the engine edits a rule in place, so nothing can forget to. + +The heart of the reference parser, one `IS` in two directions: + +```rust +for (&(x, y), tokens) in &tiles { + for is in tokens.iter().filter(|t| t.word == IS) { + for (dx, dy) in [(1, 0), (0, 1)] { + let subjects = run(&tiles, (x - dx, y - dy), (-dx, -dy), is_subject); + let complements = run(&tiles, (x + dx, y + dy), (dx, dy), is_complement); + for s in &subjects { + for c in &complements { + rules.push(Rule { + subject: s.word.clone(), + complement: c.word.clone(), + text: vec![s.id, is.id, c.id], + }); + } + } + } + } +} +``` + +```ts +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 subjects = run(tiles, o.x - dx, o.y - dy, [-dx, -dy], isSubject); + const complements = run(tiles, o.x + dx, o.y + dy, [dx, dy], isComplement); + for (const s of subjects) { + for (const c of complements) { + rules.push({ subject: s.word, complement: c.word, text: [s.id, o.id, c.id] }); + } + } + } +} +``` + +The same shape appears in [baba-is-auto](https://github.com/utilForever/baba-is-auto), a C++ simulator of a subset of the game. `Game::ParseRules` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (around line 611, commit `2305a3d`) starts with `m_ruleManager.ClearRules();` and then scans every cell horizontally and vertically; its [`ARCHITECTURE.md`](https://github.com/utilForever/baba-is-auto/blob/main/ARCHITECTURE.md) says "Rules are rebuilt after every move, so moving text can immediately change the active rule set." The rest of the simulator asks `RuleManager` (`AddRule`, `GetRules`, `HasProperty`) rather than the board. That is the split you just built: derive a table from the world, then let every system query the table. + +One warning about baba-is-auto: it is a good example of rules as data, and **not an authority** on the real game. Its own [`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md) says "all text is pushable without an explicit rule", where the real game has `TEXT IS PUSH` as a base rule that `NOT` can switch off, and its `NOT` support is partial ("Negated subjects, predicates, condition targets, repeated `NOT`, and nested forms are unsupported"), so it has no `IS NOT`. Module 7 needs both. + +**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your Part II engine knows `IS`, `AND`, `NOT`, `TEXT` and `PUSH`. `BABA` lives only in your word list. + +## Expansion Drop + +A new word arrives: `HOT`. The [HOT](https://babaiswiki.fandom.com/wiki/HOT) page: "HOT is a property that causes the object to destroy any same-float MELT objects it contacts." What HOT *does* waits for the turn in Module 9. For now your parser only has to read it. + +The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. Only your word list should. + +1. Commit your workspace and note the commit: `git add -A && git commit -m "module 6 done"`. +2. Copy the expansion test for your track from `course/06-parser/expansion/tests/rust/test_06x.rs` or `course/06-parser/expansion/tests/ts/test_06x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Add `HOT` to your word list. +4. When the tests pass, run the check from the course repository: + +```sh +node tools/expansion-check.mjs ~/my-engine HEAD +``` + +The test, `test_06x_hot_word`, places `LAVA IS HOT AND PUSH` and expects `LAVA IS HOT` and `LAVA IS PUSH`. Before the drop the parser doesn't know `HOT`: the sentence stops right after `IS`, and not even `LAVA IS PUSH` survives, because `PUSH` is only reached through `HOT AND`. The full scenario is in `course/06-parser/expansion/README.md`. + +
Discussion: what this drop measures + +If your nouns and properties are data, `HOT` is one line: + +```json +{ "name": "HOT", "role": "property" } +``` + +That is the whole reference answer, in `course/06-parser/expansion/solution/words.json` (the Module 6 word list with that line added), the same file for both tracks. + +Shapes that fail this drop: + +- **Properties as an enum in the engine.** If `Property::You | Property::Push | ...` lives under `src/engine/`, HOT is a new variant there. A typed enum feels safer, but every new word becomes an engine change, and the course's catalog alone lists fourteen properties. +- **The parser checks words, not roles.** If the complement test is `word == "YOU" || word == "PUSH" || ...`, the list of properties is in the parser. Move it to the lexer's input, and let the parser ask only "noun or property?". +- **Unknown words accepted as anything.** If your lexer treats every unknown word as a property, the test passes before the drop, and so does `BABA IS BANANA`. The drop's first run should fail. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Further reading + +- The Baba Is You Wiki's [Rule](https://babaiswiki.fandom.com/wiki/Rule) and [AND](https://babaiswiki.fandom.com/wiki/AND) pages: reading directions, stacking, base rules, when the game reparses, and AND's edge cases. Read the AND table against your parser. Then [TEXT](https://babaiswiki.fandom.com/wiki/TEXT) and [PUSH](https://babaiswiki.fandom.com/wiki/PUSH) for `TEXT IS PUSH` as a base rule. +- John Harris, [*Designing Baba Is You's delightfully innovative rule-writing system*](https://www.gamedeveloper.com/design/designing-i-baba-is-you-i-s-delightfully-innovative-rule-writing-system), Game Developer, 8 May 2019: Teikari on clearing and rebuilding the rules, on stacked text, and on the final pass that adjusts the rule list (which is Module 7). +- baba-is-auto, `Game::ParseRules` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (around line 611) and `RuleManager` in [`Sources/baba-is-auto/Rules/RuleManager.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Rules/RuleManager.cpp): compare its scan with your parser and its `HasProperty` with what Module 7 asks of you. Read it as an example of rules as data, not as an authority on the game: it hard-codes text as pushable and has no `IS NOT` ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md)). diff --git a/course/06-parser/reference/rust/.course.json b/course/06-parser/reference/rust/.course.json new file mode 100644 index 0000000..b2786b8 --- /dev/null +++ b/course/06-parser/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "06" } diff --git a/course/06-parser/reference/rust/.gitignore b/course/06-parser/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/06-parser/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/06-parser/reference/rust/Cargo.toml b/course/06-parser/reference/rust/Cargo.toml new file mode 100644 index 0000000..0987cde --- /dev/null +++ b/course/06-parser/reference/rust/Cargo.toml @@ -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" diff --git a/course/06-parser/reference/rust/README.md b/course/06-parser/reference/rust/README.md new file mode 100644 index 0000000..791d3d8 --- /dev/null +++ b/course/06-parser/reference/rust/README.md @@ -0,0 +1,16 @@ +# Module 6 reference engine (Rust) + +The course's own engine as of the end of Module 6, the first module of Part II. Part I's card engine is unchanged, and all of its tests still pass. Beside it, in `src/engine/puzzle/`, is a second engine for Baba Is You: a grid of objects and text, a lexer and a parser. Module 5's reference was the starting point, so a diff between the two shows what this module adds. + +- `cards/` (at the crate root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it. +- `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT` and the property `PUSH` of the base rule `TEXT IS PUSH`. + - `grid.rs`: the level format (`LevelDef`, `course/schema/level.schema.json`), object kinds (`""` or `"text:"`) and the board: objects on tiles, several per tile allowed. + - `word.rs`: the lexicon (every word the parser can read, with its role: noun, property or operator) and the lexer, which turns the text on the board into words by tile. Text whose word isn't in the lexicon spells nothing. + - `parse.rs`: the parser. From every `IS`, in a row and in a column, it collects the AND-joined subjects before it and the AND-joined complements after it, and emits one rule per pair. Stacked words each count. Base rules come first. + - `level.rs`: a level in play. Every change to the board recompiles the rules from scratch. + - `mod.rs`: the module's exports. +- The rest of `src/engine/`: Part I, unchanged from Module 5 except for the `pub mod puzzle;` line. +- `src/content/words.json`: the nouns and properties the parser knows, as data (`{ "name", "role" }`). `src/content/mod.rs` reads it with `include_str!` as `words()`. A new word is a new entry here. +- `src/harness.rs`: the adapter the course tests call. Module 6 adds `new_baba`, `Baba::place` and `Baba::rules`. `rules()` sorts the rules' normalised strings and keeps each distinct rule once; the engine itself keeps a rule spelled twice twice. + +Run it with the module's tests: `node tools/verify-references.mjs 06` from the course repository. diff --git a/course/06-parser/reference/rust/src/content/mod.rs b/course/06-parser/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..984e8f3 --- /dev/null +++ b/course/06-parser/reference/rust/src/content/mod.rs @@ -0,0 +1,48 @@ +//! Content: the cards and the Baba Is You words. Only this directory (and the card data in `cards/`) may name +//! specific cards, or specific Baba Is You nouns. +//! +//! From Module 5 every card is a JSON file in `cards/` at the crate root, in the +//! course's card format (`course/schema/card.schema.json`). The functions that +//! were card effects in Modules 2–4 are gone: the engine interprets the data. +//! `start-module` copies the course's card files into `cards/`; add your own there. + +use std::path::Path; +use std::sync::OnceLock; + +use crate::engine::CardDef; + +/// Every card in `cards/`, in file-name order. Read once per test run. +pub fn cards() -> Vec { + static CARDS: OnceLock> = 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 { + 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 { + serde_json::from_str(include_str!("words.json")) + .expect("src/content/words.json is not a word list") +} diff --git a/course/06-parser/reference/rust/src/content/words.json b/course/06-parser/reference/rust/src/content/words.json new file mode 100644 index 0000000..25374e2 --- /dev/null +++ b/course/06-parser/reference/rust/src/content/words.json @@ -0,0 +1,15 @@ +[ + { "name": "BABA", "role": "noun" }, + { "name": "ROCK", "role": "noun" }, + { "name": "WALL", "role": "noun" }, + { "name": "FLAG", "role": "noun" }, + { "name": "LAVA", "role": "noun" }, + { "name": "KEY", "role": "noun" }, + { "name": "DOOR", "role": "noun" }, + { "name": "SKULL", "role": "noun" }, + { "name": "WATER", "role": "noun" }, + { "name": "YOU", "role": "property" }, + { "name": "WIN", "role": "property" }, + { "name": "PUSH", "role": "property" }, + { "name": "STOP", "role": "property" } +] diff --git a/course/06-parser/reference/rust/src/engine/assemble.rs b/course/06-parser/reference/rust/src/engine/assemble.rs new file mode 100644 index 0000000..85f4da9 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/assemble.rs @@ -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 " (Assembled)". +pub fn assemble(base: &CardDef, donor: &CardDef, half: Half) -> Result { + 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 { + // 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 = 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 = 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 = 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)) +} diff --git a/course/06-parser/reference/rust/src/engine/aura.rs b/course/06-parser/reference/rust/src/engine/aura.rs new file mode 100644 index 0000000..0656db4 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/aura.rs @@ -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 +} diff --git a/course/06-parser/reference/rust/src/engine/card.rs b/course/06-parser/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..8171ec2 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/card.rs @@ -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, +} + +/// 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, + /// `text[..split]` is the top half, `text[split..]` the bottom half. + pub split: usize, +} + +/// A triggered ability: "whenever , ". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trigger { + pub on: TriggerOn, + pub effects: Vec, +} + +/// 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, + /// Auras this minion has while in play. + pub auras: Vec, + /// What the card does when cast (a spell's effects). + pub on_cast: Vec, + /// 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 = 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, +} + +impl CardLibrary { + pub fn new(defs: Vec) -> 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 + } +} diff --git a/course/06-parser/reference/rust/src/engine/death.rs b/course/06-parser/reference/rust/src/engine/death.rs new file mode 100644 index 0000000..69a00e3 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/death.rs @@ -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 { + // 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 +} diff --git a/course/06-parser/reference/rust/src/engine/effect.rs b/course/06-parser/reference/rust/src/engine/effect.rs new file mode 100644 index 0000000..9c9f438 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/effect.rs @@ -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 { + 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 { + let world = ctx.world; + let others = |ids: Vec| -> Vec { + 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 { + match ctx.source.and_then(|s| ctx.world.board_index(s)) { + Some(index) => Some(index + 1), + None => ctx.position, + } +} diff --git a/course/06-parser/reference/rust/src/engine/event.rs b/course/06-parser/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..5d4f33e --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/event.rs @@ -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, + }, +} diff --git a/course/06-parser/reference/rust/src/engine/mod.rs b/course/06-parser/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..d21d161 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/mod.rs @@ -0,0 +1,43 @@ +//! Engine core: generic rules machinery. Never name a specific card here. +//! +//! Module 1: every card in the game is an entity addressed by an id handle. +//! The engine knows which zone each entity is in, who owns it, and when it +//! entered play. +//! +//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +//! action queue that resolves a Sequence depth-first, recording an event log. +//! +//! Module 3: deferred death. Damage and destroy effects only mark minions; the +//! Death Creation Step removes them when the outermost Phase ends, and Death +//! Phases resolve their Deathrattles until no new deaths occur. +//! +//! Module 4: auras as card data, Aura Updates at defined points, and one +//! routine that decides how many times a queued trigger activates. Playing a +//! minion from hand, and Battlecries. +//! +//! Module 5: behaviour as data. Cards are definitions in the course's card format, +//! loaded from JSON; one interpreter turns their effects into actions. Assemble +//! builds a new card by swapping half of one card's text for half of another's. +//! +//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game, +//! beside the first. +//! Module 6: the grid, the lexer and the parser. + +mod assemble; +mod aura; +mod card; +mod death; +mod effect; +mod event; +pub mod puzzle; +mod queue; +mod world; + +pub use assemble::{assemble, register_assembled, AssembleError, Half}; +pub use card::{ + Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target, + Trigger, TriggerOn, +}; +pub use event::Event; +pub use queue::{run_sequence, summon, Action, Ctx}; +pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/06-parser/reference/rust/src/engine/puzzle/grid.rs b/course/06-parser/reference/rust/src/engine/puzzle/grid.rs new file mode 100644 index 0000000..adeaac7 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/puzzle/grid.rs @@ -0,0 +1,96 @@ +//! The grid: objects on tiles. `x` grows right, `y` grows down, and any number of +//! objects may share a tile. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +pub type ObjectId = u32; + +/// A level in the course's level format (`course/schema/level.schema.json`). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct LevelDef { + pub width: i32, + pub height: i32, + #[serde(default)] + pub objects: Vec, +} + +#[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 `""`, the noun in lower case), +/// or a piece of text carrying one word (kind `"text:"`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kind { + Thing { noun: String }, + Text { word: String }, +} + +impl Kind { + const TEXT_PREFIX: &'static str = "text:"; + + pub fn parse(kind: &str) -> Kind { + match kind.strip_prefix(Self::TEXT_PREFIX) { + Some(word) => Kind::Text { + word: word.to_uppercase(), + }, + None => Kind::Thing { + noun: kind.to_uppercase(), + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct Object { + pub id: ObjectId, + pub kind: Kind, + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone, Default)] +pub struct Board { + pub width: i32, + pub height: i32, + objects: BTreeMap, + next_id: ObjectId, +} + +impl Board { + pub fn new(width: i32, height: i32) -> Board { + Board { + width, + height, + ..Default::default() + } + } + + pub fn in_bounds(&self, x: i32, y: i32) -> bool { + (0..self.width).contains(&x) && (0..self.height).contains(&y) + } + + /// Add an object; ids start at 1 and are never reused. Panics off the board. + pub fn place(&mut self, kind: Kind, x: i32, y: i32) -> ObjectId { + assert!( + self.in_bounds(x, y), + "({x}, {y}) is outside the {}x{} level", + self.width, + self.height + ); + self.next_id += 1; + let id = self.next_id; + self.objects.insert(id, Object { id, kind, x, y }); + id + } + + /// Every object, oldest first. + pub fn objects(&self) -> impl Iterator { + self.objects.values() + } +} diff --git a/course/06-parser/reference/rust/src/engine/puzzle/level.rs b/course/06-parser/reference/rust/src/engine/puzzle/level.rs new file mode 100644 index 0000000..6210f29 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/puzzle/level.rs @@ -0,0 +1,47 @@ +//! A level in play: the board, the words content provides, and the rules compiled +//! from them. Every change to the board recompiles the rules from scratch. + +use super::grid::{Board, Kind, LevelDef, ObjectId}; +use super::parse::{parse, Rule}; +use super::word::{Lexicon, WordDef}; + +#[derive(Debug, Clone)] +pub struct Level { + board: Board, + lexicon: Lexicon, + rules: Vec, +} + +impl Level { + pub fn new(def: &LevelDef, words: &[WordDef]) -> Level { + let mut level = Level { + board: Board::new(def.width, def.height), + lexicon: Lexicon::new(words), + rules: vec![], + }; + for o in &def.objects { + level.board.place(Kind::parse(&o.kind), o.x, o.y); + } + level.reparse(); + level + } + + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { + let id = self.board.place(Kind::parse(kind), x, y); + self.reparse(); + id + } + + pub fn board(&self) -> &Board { + &self.board + } + + /// Every rule in force, as parsed (duplicates included). + pub fn rules(&self) -> &[Rule] { + &self.rules + } + + fn reparse(&mut self) { + self.rules = parse(&self.board, &self.lexicon); + } +} diff --git a/course/06-parser/reference/rust/src/engine/puzzle/mod.rs b/course/06-parser/reference/rust/src/engine/puzzle/mod.rs new file mode 100644 index 0000000..7ff44b9 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/puzzle/mod.rs @@ -0,0 +1,17 @@ +//! Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is +//! compiled from the text on the board, and recompiled whenever the board changes. +//! +//! Module 6: the grid, the lexer (text objects to words with grammatical roles) +//! and the parser (words to rules). The engine knows the operators `IS`, `AND`, +//! `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word, +//! nouns included, is data handed in by content. + +mod grid; +mod level; +mod parse; +mod word; + +pub use grid::{Board, Kind, LevelDef, LevelObject, Object, ObjectId}; +pub use level::Level; +pub use parse::{parse, Rule, BASE_RULES}; +pub use word::{lex, Lexicon, Role, Token, WordDef}; diff --git a/course/06-parser/reference/rust/src/engine/puzzle/parse.rs b/course/06-parser/reference/rust/src/engine/puzzle/parse.rs new file mode 100644 index 0000000..8a9314b --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/puzzle/parse.rs @@ -0,0 +1,95 @@ +//! The parser: words on the grid become rules. +//! +//! Grammar (Module 6): `NOUN [AND NOUN]* IS (NOUN|PROPERTY) [AND (NOUN|PROPERTY)]*`, +//! read left to right in a row and top to bottom in a column. The parser starts at +//! every `IS`, collects the subjects before it and the complements after it, and +//! emits one rule per pair: AND distributes over both sides. A tile holding several +//! words contributes each of them, so stacked text yields every possible rule. + +use std::collections::BTreeMap; +use std::fmt; + +use super::grid::{Board, ObjectId}; +use super::word::{lex, Lexicon, Role, Token, AND, IS, TEXT}; + +/// One rule: `subject IS complement`. `text` is the three text objects that spell +/// it (subject, `IS`, complement), or empty for a base rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub subject: String, + pub complement: String, + pub text: Vec, +} + +impl fmt::Display for Rule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {IS} {}", self.subject, self.complement) + } +} + +/// Rules in force with no text: the game's base rules (`(subject, complement)`). +pub const BASE_RULES: &[(&str, &str)] = &[(TEXT, "PUSH")]; + +type Tiles = BTreeMap<(i32, i32), Vec>; + +/// Every rule the board spells, base rules first. A rule spelled by two different +/// sets of text objects appears twice. +pub fn parse(board: &Board, lexicon: &Lexicon) -> Vec { + let tiles = lex(board, lexicon); + let mut rules: Vec = BASE_RULES + .iter() + .map(|&(subject, complement)| Rule { + subject: subject.to_string(), + complement: complement.to_string(), + text: vec![], + }) + .collect(); + for (&(x, y), tokens) in &tiles { + for is in tokens.iter().filter(|t| t.word == IS) { + for (dx, dy) in [(1, 0), (0, 1)] { + let subjects = run(&tiles, (x - dx, y - dy), (-dx, -dy), is_subject); + let complements = run(&tiles, (x + dx, y + dy), (dx, dy), is_complement); + for s in &subjects { + for c in &complements { + rules.push(Rule { + subject: s.word.clone(), + complement: c.word.clone(), + text: vec![s.id, is.id, c.id], + }); + } + } + } + } + } + rules +} + +fn is_subject(t: &Token) -> bool { + t.role == Role::Noun +} + +fn is_complement(t: &Token) -> bool { + matches!(t.role, Role::Noun | Role::Property) +} + +/// A run of words joined by AND, walking from `at` in `step` direction: the words +/// at `at` that `fits` accepts, then, if the next tile holds an AND, the run that +/// starts on the tile after it. Empty if `at` holds no fitting word. +fn run<'a>( + tiles: &'a Tiles, + at: (i32, i32), + step: (i32, i32), + fits: fn(&Token) -> bool, +) -> Vec<&'a Token> { + let words_at = |p: (i32, i32)| tiles.get(&p).map(Vec::as_slice).unwrap_or(&[]); + let mut found: Vec<&Token> = words_at(at).iter().filter(|t| fits(t)).collect(); + if found.is_empty() { + return found; + } + let and_at = (at.0 + step.0, at.1 + step.1); + if words_at(and_at).iter().any(|t| t.word == AND) { + let next = (and_at.0 + step.0, and_at.1 + step.1); + found.extend(run(tiles, next, step, fits)); + } + found +} diff --git a/course/06-parser/reference/rust/src/engine/puzzle/word.rs b/course/06-parser/reference/rust/src/engine/puzzle/word.rs new file mode 100644 index 0000000..12ed7e3 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/puzzle/word.rs @@ -0,0 +1,84 @@ +//! The lexer: text objects become words with a grammatical role. +//! +//! The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`. +//! Every other word comes from content as data: the engine never names a noun. + +use std::collections::{BTreeMap, HashMap}; + +use serde::Deserialize; + +use super::grid::{Board, Kind, ObjectId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Noun, + Property, + Operator, +} + +/// One entry of the word list content provides: `{ "name": "", "role": "noun" }`. +#[derive(Debug, Clone, Deserialize)] +pub struct WordDef { + pub name: String, + pub role: Role, +} + +pub const IS: &str = "IS"; +pub const AND: &str = "AND"; +pub const NOT: &str = "NOT"; +/// The meta-noun for every text object. +pub const TEXT: &str = "TEXT"; + +/// Every word the parser can read, with its role. +#[derive(Debug, Clone, Default)] +pub struct Lexicon { + roles: HashMap, +} + +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 = words + .iter() + .map(|w| (w.name.to_uppercase(), w.role)) + .collect(); + for op in [IS, AND, NOT] { + roles.insert(op.to_string(), Role::Operator); + } + roles.insert(TEXT.to_string(), Role::Noun); + Lexicon { roles } + } + + /// `None` for a word the lexicon doesn't know: such text spells nothing. + pub fn role(&self, word: &str) -> Option { + self.roles.get(word).copied() + } +} + +/// A piece of text the parser can read: which object, which word, which role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub id: ObjectId, + pub word: String, + pub role: Role, +} + +/// The words on the board, by tile; a tile's words in object order (oldest first). +/// Text whose word the lexicon doesn't know is left out. +pub fn lex(board: &Board, lexicon: &Lexicon) -> BTreeMap<(i32, i32), Vec> { + let mut tiles: BTreeMap<(i32, i32), Vec> = 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 +} diff --git a/course/06-parser/reference/rust/src/engine/queue.rs b/course/06-parser/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..f81c39a --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/queue.rs @@ -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, + amount: i32, + }, + Draw { + player: PlayerId, + }, + GainArmor { + player: PlayerId, + amount: i32, + }, + GainAttack { + id: EntityId, + amount: i32, + }, + Cast { + card: String, + player: PlayerId, + target: Option, + }, + /// Mark each target pending destroy. They leave play in the next Death Creation Step. + Destroy { + targets: Vec, + }, + /// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`. + Summon { + card: String, + player: PlayerId, + at: Option, + }, + /// 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, + /// The chosen target, if any. + pub target: Option, + /// 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, +} + +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 { + 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) -> 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, + target: Option, + position: Option, +) { + 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 { + 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, + } +} diff --git a/course/06-parser/reference/rust/src/engine/world.rs b/course/06-parser/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..b2224e2 --- /dev/null +++ b/course/06-parser/reference/rust/src/engine/world.rs @@ -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, + 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, + hand: Vec, + board: Vec, + graveyard: Vec, + hero: EntityId, + armor: i32, +} + +impl Side { + fn zone(&self, zone: Zone) -> &Vec { + 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 { + 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 String>; + +pub struct World { + library: CardLibrary, + chooser: Chooser, + /// Indexed by `EntityId`. + entities: Vec, + sides: [Side; 2], + next_order_of_play: u32, + log: Vec, + /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). + auras: Vec, +} + +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, + ) -> 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) { + 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 { + 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 { + let mut ids: Vec = 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 { + 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 { + 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) { + 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 }); + } +} diff --git a/course/06-parser/reference/rust/src/harness.rs b/course/06-parser/reference/rust/src/harness.rs new file mode 100644 index 0000000..01688bd --- /dev/null +++ b/course/06-parser/reference/rust/src/harness.rs @@ -0,0 +1,223 @@ +//! Test harness: the thin adapter the course tests call. It translates each +//! call into engine operations and holds no rules of its own. + +use crate::content; +use crate::engine::{self, puzzle, run_sequence, summon, Action, CardLibrary, Event, World}; + +pub use crate::engine::{AssembleError, CardDef, Half, Zone}; + +pub type Id = u32; +pub type Player = u8; + +#[derive(Debug, Default, Clone)] +pub struct GameOpts { + pub decks: Option<[Vec; 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 { + 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 { + self.world.zone(player, Zone::Board).to_vec() + } + pub fn hand(&self, player: Player) -> Vec { + 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) { + 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 { + 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) { + 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, `" (Assembled)"`. + pub fn assemble( + &mut self, + base: &str, + donor: &str, + half: Half, + ) -> Result { + let base = &self.world.card(base).def; + let donor = &self.world.card(donor).def; + let def = engine::assemble(base, donor, half)?; + Ok(engine::register_assembled(&mut self.world, def)) + } + + pub fn cost(&self, id: Id) -> i32 { + self.world.cost(id) + } + + /// Who makes choices (Assemble's base, donor and half): called with the kind of + /// choice (`"base"`, `"donor"`, `"half"`) and the options; returns one of them. + pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) { + self.world.set_chooser(Box::new(chooser)); + } +} + +// ======== Part II: Baba Is You ======== + +pub use crate::engine::puzzle::{LevelDef, LevelObject}; + +/// A Baba Is You level in play (Part II). +pub struct Baba { + level: puzzle::Level, +} + +/// A level from a level definition; its text is parsed straight away. +pub fn new_baba(level: LevelDef) -> Baba { + Baba { + level: puzzle::Level::new(&level, &content::words()), + } +} + +impl Baba { + // ---- Module 6 ---- + + /// Add an object (`"baba"`) or a piece of text (`"text:BABA"`) at (x, y). + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id { + self.level.place(kind, x, y) + } + + /// Every distinct rule in force, normalised (`"BABA IS YOU"`) and sorted. + pub fn rules(&self) -> Vec { + let mut rules: Vec = self.level.rules().iter().map(|r| r.to_string()).collect(); + rules.sort(); + rules.dedup(); + rules + } +} diff --git a/course/06-parser/reference/rust/src/lib.rs b/course/06-parser/reference/rust/src/lib.rs new file mode 100644 index 0000000..f5868fb --- /dev/null +++ b/course/06-parser/reference/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod content; +pub mod engine; +pub mod harness; diff --git a/course/06-parser/reference/ts/.course.json b/course/06-parser/reference/ts/.course.json new file mode 100644 index 0000000..9125f3c --- /dev/null +++ b/course/06-parser/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "06" } diff --git a/course/06-parser/reference/ts/.gitignore b/course/06-parser/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/06-parser/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/06-parser/reference/ts/README.md b/course/06-parser/reference/ts/README.md new file mode 100644 index 0000000..9160270 --- /dev/null +++ b/course/06-parser/reference/ts/README.md @@ -0,0 +1,16 @@ +# Module 6 reference engine (TypeScript) + +The course's own engine as of the end of Module 6, the first module of Part II. Part I's card engine is unchanged, and all of its tests still pass. Beside it, in `src/engine/puzzle/`, is a second engine for Baba Is You: a grid of objects and text, a lexer and a parser. Module 5's reference was the starting point, so a diff between the two shows what this module adds. + +- `cards/` (at the 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` and the property `PUSH` of the base rule `TEXT IS PUSH`. + - `grid.ts`: the level format (`LevelDef`, `course/schema/level.schema.json`), object kinds (`""` or `"text:"`) and the board: objects on tiles, several per tile allowed. + - `word.ts`: the lexicon (every word the parser can read, with its role: noun, property or operator) and the lexer, which turns the text on the board into words by tile. Text whose word isn't in the lexicon spells nothing. + - `parse.ts`: the parser. From every `IS`, in a row and in a column, it collects the AND-joined subjects before it and the AND-joined complements after it, and emits one rule per pair. Stacked words each count. Base rules come first. + - `level.ts`: a level in play. Every change to the board recompiles the rules from scratch. + - `index.ts`: the module's exports. +- The rest of `src/engine/`: Part I, unchanged from Module 5 except for the export that adds `puzzle`. +- `src/content/words.json`: the nouns and properties the parser knows, as data (`{ "name", "role" }`). `src/content/index.ts` reads it with a JSON import as `words()` (`tsconfig.json` gains `resolveJsonModule` so `tsc` accepts the import; Vitest needs nothing). A new word is a new entry here. +- `src/harness.ts`: the adapter the course tests call. Module 6 adds `newBaba`, and `place` and `rules` on `Baba`. `rules()` sorts the rules' normalised strings and keeps each distinct rule once; the engine itself keeps a rule spelled twice twice. + +Run it with the module's tests: `node tools/verify-references.mjs 06` from the course repository. diff --git a/course/06-parser/reference/ts/package-lock.json b/course/06-parser/reference/ts/package-lock.json new file mode 100644 index 0000000..8fa242d --- /dev/null +++ b/course/06-parser/reference/ts/package-lock.json @@ -0,0 +1,1496 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "systemic-design-workspace", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/course/06-parser/reference/ts/package.json b/course/06-parser/reference/ts/package.json new file mode 100644 index 0000000..8d2da89 --- /dev/null +++ b/course/06-parser/reference/ts/package.json @@ -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" + } +} diff --git a/course/06-parser/reference/ts/src/content/index.ts b/course/06-parser/reference/ts/src/content/index.ts new file mode 100644 index 0000000..77682c6 --- /dev/null +++ b/course/06-parser/reference/ts/src/content/index.ts @@ -0,0 +1,32 @@ +/// +// 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"; + +const files = import.meta.glob("../../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[]; +} diff --git a/course/06-parser/reference/ts/src/content/words.json b/course/06-parser/reference/ts/src/content/words.json new file mode 100644 index 0000000..25374e2 --- /dev/null +++ b/course/06-parser/reference/ts/src/content/words.json @@ -0,0 +1,15 @@ +[ + { "name": "BABA", "role": "noun" }, + { "name": "ROCK", "role": "noun" }, + { "name": "WALL", "role": "noun" }, + { "name": "FLAG", "role": "noun" }, + { "name": "LAVA", "role": "noun" }, + { "name": "KEY", "role": "noun" }, + { "name": "DOOR", "role": "noun" }, + { "name": "SKULL", "role": "noun" }, + { "name": "WATER", "role": "noun" }, + { "name": "YOU", "role": "property" }, + { "name": "WIN", "role": "property" }, + { "name": "PUSH", "role": "property" }, + { "name": "STOP", "role": "property" } +] diff --git a/course/06-parser/reference/ts/src/engine/assemble.ts b/course/06-parser/reference/ts/src/engine/assemble.ts new file mode 100644 index 0000000..a02c62d --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/assemble.ts @@ -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 " (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"); +} diff --git a/course/06-parser/reference/ts/src/engine/aura.ts b/course/06-parser/reference/ts/src/engine/aura.ts new file mode 100644 index 0000000..ba66ce5 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/aura.ts @@ -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; +} diff --git a/course/06-parser/reference/ts/src/engine/card.ts b/course/06-parser/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..0d3e6be --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/card.ts @@ -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 , ". */ +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(); + + 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)); + } +} diff --git a/course/06-parser/reference/ts/src/engine/death.ts b/course/06-parser/reference/ts/src/engine/death.ts new file mode 100644 index 0000000..6dc715d --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/death.ts @@ -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; +} diff --git a/course/06-parser/reference/ts/src/engine/effect.ts b/course/06-parser/reference/ts/src/engine/effect.ts new file mode 100644 index 0000000..17a1a61 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/effect.ts @@ -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 = (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; +} diff --git a/course/06-parser/reference/ts/src/engine/event.ts b/course/06-parser/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..a8be9c7 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/event.ts @@ -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 }; diff --git a/course/06-parser/reference/ts/src/engine/index.ts b/course/06-parser/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..285bffb --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/index.ts @@ -0,0 +1,43 @@ +// Engine core: generic rules machinery. Never name a specific card here. +// +// Module 1: every card in the game is an entity addressed by an id handle. +// The engine knows which zone each entity is in, who owns it, and when it +// entered play. +// +// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +// action queue that resolves a Sequence depth-first, recording an event log. +// +// Module 3: deferred death. Damage and destroy effects only mark minions; the +// Death Creation Step removes them when the outermost Phase ends, and Death +// Phases resolve their Deathrattles until no new deaths occur. +// +// Module 4: auras as card data, Aura Updates at defined points, and one +// routine that decides how many times a queued trigger activates. Playing a +// minion from hand, and Battlecries. +// +// Module 5: behaviour as data. Cards are definitions in the course's card format, +// loaded from JSON; one interpreter turns their effects into actions. Assemble +// builds a new card by swapping half of one card's text for half of another's. +// +// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game, +// beside the first. +// Module 6: the grid, the lexer and the parser. + +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"; diff --git a/course/06-parser/reference/ts/src/engine/puzzle/grid.ts b/course/06-parser/reference/ts/src/engine/puzzle/grid.ts new file mode 100644 index 0000000..dfc8f2d --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/puzzle/grid.ts @@ -0,0 +1,67 @@ +// 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 `""`, the noun in lower case), + * or a piece of text carrying one word (kind `"text:"`). + */ +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() }; + }, +}; + +export interface Obj { + id: ObjectId; + kind: Kind; + x: number; + y: number; +} + +export class Board { + private readonly objectsById = new Map(); + 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; + } + + /** Every object, oldest first. */ + objects(): Obj[] { + return [...this.objectsById.values()]; + } +} diff --git a/course/06-parser/reference/ts/src/engine/puzzle/index.ts b/course/06-parser/reference/ts/src/engine/puzzle/index.ts new file mode 100644 index 0000000..bb0a79e --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/puzzle/index.ts @@ -0,0 +1,12 @@ +// 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. + +export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid"; +export { Level } from "./level"; +export { BASE_RULES, parse, ruleText, type Rule } from "./parse"; +export { lex, Lexicon, type Role, type Token, type WordDef } from "./word"; diff --git a/course/06-parser/reference/ts/src/engine/puzzle/level.ts b/course/06-parser/reference/ts/src/engine/puzzle/level.ts new file mode 100644 index 0000000..ebe0e0e --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/puzzle/level.ts @@ -0,0 +1,34 @@ +// 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. + +import { Board, Kind, type LevelDef, type ObjectId } from "./grid"; +import { parse, type Rule } from "./parse"; +import { Lexicon, type WordDef } from "./word"; + +export class Level { + readonly board: Board; + private readonly lexicon: Lexicon; + private compiled: Rule[] = []; + + constructor(def: LevelDef, words: readonly WordDef[]) { + 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; + } + + /** Every rule in force, as parsed (duplicates included). */ + rules(): readonly Rule[] { + return this.compiled; + } + + private reparse(): void { + this.compiled = parse(this.board, this.lexicon); + } +} diff --git a/course/06-parser/reference/ts/src/engine/puzzle/parse.ts b/course/06-parser/reference/ts/src/engine/puzzle/parse.ts new file mode 100644 index 0000000..6709d01 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/puzzle/parse.ts @@ -0,0 +1,73 @@ +// The parser: words on the grid become rules. +// +// Grammar (Module 6): `NOUN [AND NOUN]* IS (NOUN|PROPERTY) [AND (NOUN|PROPERTY)]*`, +// read left to right in a row and top to bottom in a column. The parser starts at +// every `IS`, collects the subjects before it and the complements after it, and +// emits one rule per pair: AND distributes over both sides. A tile holding several +// words contributes each of them, so stacked text yields every possible rule. + +import type { Board, ObjectId } from "./grid"; +import { AND, IS, lex, TEXT, tileKey, type Lexicon, type Token } from "./word"; + +/** + * One rule: `subject IS complement`. `text` is the three text objects that spell + * it (subject, `IS`, complement), or empty for a base rule. + */ +export interface Rule { + subject: string; + complement: string; + text: ObjectId[]; +} + +/** The normalised form: `"TEXT IS PUSH"`. */ +export const ruleText = (r: Rule): string => `${r.subject} ${IS} ${r.complement}`; + +/** Rules in force with no text: the game's base rules (`[subject, complement]`). */ +export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]]; + +type Tiles = Map; +type Step = readonly [number, number]; + +const isSubject = (t: Token): boolean => t.role === "noun"; +const isComplement = (t: Token): boolean => t.role === "noun" || t.role === "property"; + +/** + * Every rule the board spells, base rules first. 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, complement]) => ({ subject, complement, 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 subjects = run(tiles, o.x - dx, o.y - dy, [-dx, -dy], isSubject); + const complements = run(tiles, o.x + dx, o.y + dy, [dx, dy], isComplement); + for (const s of subjects) { + for (const c of complements) { + rules.push({ subject: s.word, complement: c.word, text: [s.id, o.id, c.id] }); + } + } + } + } + return rules; +} + +/** + * A run of words joined by AND, walking from (x, y) in `step` direction: the words + * at (x, y) that `fits` accepts, then, if the next tile holds an AND, the run that + * starts on the tile after it. Empty if (x, y) holds no fitting word. + */ +function run(tiles: Tiles, x: number, y: number, step: Step, fits: (t: Token) => boolean): Token[] { + const wordsAt = (px: number, py: number) => tiles.get(tileKey(px, py)) ?? []; + const found = wordsAt(x, y).filter(fits); + if (found.length === 0) return found; + const [ax, ay] = [x + step[0], y + step[1]]; + if (wordsAt(ax, ay).some((t) => t.word === AND)) { + found.push(...run(tiles, ax + step[0], ay + step[1], step, fits)); + } + return found; +} diff --git a/course/06-parser/reference/ts/src/engine/puzzle/word.ts b/course/06-parser/reference/ts/src/engine/puzzle/word.ts new file mode 100644 index 0000000..6fb2e49 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/puzzle/word.ts @@ -0,0 +1,65 @@ +// 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. + +import type { Board, ObjectId } from "./grid"; + +export type Role = "noun" | "property" | "operator"; + +/** One entry of the word list content provides: `{ "name": "", "role": "noun" }`. */ +export interface WordDef { + name: string; + role: Role; +} + +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(); + + /** 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); + for (const op of [IS, AND, NOT]) this.roles.set(op, "operator"); + this.roles.set(TEXT, "noun"); + } + + /** `undefined` for a word the lexicon doesn't know: such text spells nothing. */ + role(word: string): Role | undefined { + return this.roles.get(word); + } +} + +/** 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 { + const tiles = new Map(); + 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; +} diff --git a/course/06-parser/reference/ts/src/engine/queue.ts b/course/06-parser/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..1f27521 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/queue.ts @@ -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; + } +} diff --git a/course/06-parser/reference/ts/src/engine/world.ts b/course/06-parser/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..1522da4 --- /dev/null +++ b/course/06-parser/reference/ts/src/engine/world.ts @@ -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 & { 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 }); + } +} diff --git a/course/06-parser/reference/ts/src/harness.ts b/course/06-parser/reference/ts/src/harness.ts new file mode 100644 index 0000000..e987d27 --- /dev/null +++ b/course/06-parser/reference/ts/src/harness.ts @@ -0,0 +1,136 @@ +// 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, 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 " (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 { 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"), sorted +} + +/** 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()); + return { + place: (kind, x, y) => lvl.place(kind, x, y), + rules: () => [...new Set(lvl.rules().map(puzzle.ruleText))].sort(), + }; +} diff --git a/course/06-parser/reference/ts/tsconfig.json b/course/06-parser/reference/ts/tsconfig.json new file mode 100644 index 0000000..604c7c5 --- /dev/null +++ b/course/06-parser/reference/ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src", "tests"] +} diff --git a/course/06-parser/reference/ts/vitest.config.ts b/course/06-parser/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/06-parser/reference/ts/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { include: ["tests/**/*.test.ts"], passWithNoTests: true }, +}); diff --git a/course/06-parser/scenarios.md b/course/06-parser/scenarios.md new file mode 100644 index 0000000..80fff49 --- /dev/null +++ b/course/06-parser/scenarios.md @@ -0,0 +1,39 @@ +# Module 6 scenarios: Grid, lexer and parser + +Each heading is a test name. The Rust test lives in `tests/rust/test_06.rs` and the TypeScript test in `tests/ts/test_06.test.ts`; both state exactly what is written here. + +Part II has its own harness object, `Baba`, made by `newBaba(level)` from a level in the format of `course/schema/level.schema.json`: a `width`, a `height` and a list of `objects`, each `{kind, x, y}`. `x` grows right and `y` grows down. A kind is either an object (`"baba"`) or a word of text (`"text:BABA"`). `place(kind, x, y)` adds one more object to the level and returns its id. Several objects may share a tile. + +`rules()` returns every rule the board currently spells, as normalised strings such as `"BABA IS YOU"`, sorted, each distinct rule once. The base rule `"TEXT IS PUSH"` is always in it, although no text spells it. Duplicates are a real thing in the game (stacked text "will attempt to produce all possible rules", and a rule spelled twice can duplicate objects, see `docs/research/baba-is-you.md` §e), so the engine may hold the same rule more than once, but `rules()` is the set of distinct rules and no test counts duplicates. + +Words used: the nouns `BABA`, `ROCK`, `FLAG`; the properties `YOU`, `WIN`, `PUSH`, `STOP`; the operators `IS` and `AND`; the meta-noun `TEXT` (only through the base rule). Rules read left to right in a row and top to bottom in a column. A test writes a sentence as its words with their tiles: `BABA (0,0) IS (1,0) YOU (2,0)` is three text objects, `text:BABA` at x 0, y 0, and so on. + +### test_06_horizontal_rule + +- Setup: `newBaba` with a level 5 wide and 3 high whose `objects` are, in this order: `BABA (0,0) IS (1,0) YOU (2,0)`; an object `baba` at (3,2); `YOU (0,2) IS (1,2) FLAG (2,2)`. +- Action: none. The rules come from the level's objects. +- Assertions: + - `rules()` is exactly `["BABA IS YOU", "TEXT IS PUSH"]`. + - Row 2 spells nothing: `YOU IS FLAG` has a property where a noun must start the sentence, and the parser never reads right to left (`FLAG IS YOU`). The `baba` object is not text and plays no part in parsing. + +### test_06_crossword_shared_is + +- Setup: `newBaba` with an empty level 3 wide and 3 high. Place `BABA (0,1)`, `IS (1,1)`, `YOU (2,1)`, then `FLAG (1,0)`. +- Assertions 1: `rules()` is exactly `["BABA IS YOU", "TEXT IS PUSH"]`. `FLAG` above `IS` is half a sentence. +- Action: place `WIN (1,2)`. +- Assertions 2: `rules()` is exactly `["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"]`. The `IS` at (1,1) is in a row sentence and a column sentence at once, and the rules were recompiled when the board changed. + +### test_06_and_distributes + +- Setup: `newBaba` with an empty level 7 wide and 1 high. Place `BABA (0,0) AND (1,0) ROCK (2,0) IS (3,0) PUSH (4,0) AND (5,0) STOP (6,0)`. +- Assertions: `rules()` is exactly `["BABA IS PUSH", "BABA IS STOP", "ROCK IS PUSH", "ROCK IS STOP", "TEXT IS PUSH"]`. AND distributes over both sides: two subjects times two complements is four rules. + +### test_06_stacked_text_all_combinations + +- Setup: `newBaba` with an empty level 3 wide and 1 high. Place `text:BABA` at (0,0), then `text:ROCK` also at (0,0), then `IS (1,0)` and `YOU (2,0)`. +- Assertions: `rules()` is exactly `["BABA IS YOU", "ROCK IS YOU", "TEXT IS PUSH"]`. Both stacked words start a sentence: the parser produces every rule the stack allows. + +### test_06_default_text_is_push + +- Setup: `newBaba` with a level 3 wide and 3 high whose only object is a `baba` at (1,1). +- Assertions: `rules()` is exactly `["TEXT IS PUSH"]`. No text spells it: it is a base rule of the game. diff --git a/course/06-parser/tests/rust/test_06.rs b/course/06-parser/tests/rust/test_06.rs new file mode 100644 index 0000000..c318b71 --- /dev/null +++ b/course/06-parser/tests/rust/test_06.rs @@ -0,0 +1,118 @@ +// Module 6: Grid, lexer and parser. Each test matches a heading in scenarios.md. +use engine::harness::*; + +/// An empty level `width` wide and `height` high. +fn empty_level(width: i32, height: i32) -> LevelDef { + LevelDef { + width, + height, + ..Default::default() + } +} + +fn object(kind: &str, x: i32, y: i32) -> LevelObject { + LevelObject { + kind: kind.to_string(), + x, + y, + } +} + +/// Place one text object per word: `(word, x, y)`. +fn write(b: &mut Baba, words: &[(&str, i32, i32)]) { + for &(word, x, y) in words { + b.place(&format!("text:{word}"), x, y); + } +} + +#[test] +fn test_06_horizontal_rule() { + let b = new_baba(LevelDef { + width: 5, + height: 3, + objects: vec![ + object("text:BABA", 0, 0), + object("text:IS", 1, 0), + object("text:YOU", 2, 0), + object("baba", 3, 2), + object("text:YOU", 0, 2), + object("text:IS", 1, 2), + object("text:FLAG", 2, 2), + ], + ..Default::default() + }); + + assert_eq!(b.rules(), vec!["BABA IS YOU", "TEXT IS PUSH"]); +} + +#[test] +fn test_06_crossword_shared_is() { + let mut b = new_baba(empty_level(3, 3)); + write( + &mut b, + &[("BABA", 0, 1), ("IS", 1, 1), ("YOU", 2, 1), ("FLAG", 1, 0)], + ); + + assert_eq!(b.rules(), vec!["BABA IS YOU", "TEXT IS PUSH"]); + + b.place("text:WIN", 1, 2); + + assert_eq!( + b.rules(), + vec!["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"] + ); +} + +#[test] +fn test_06_and_distributes() { + let mut b = new_baba(empty_level(7, 1)); + write( + &mut b, + &[ + ("BABA", 0, 0), + ("AND", 1, 0), + ("ROCK", 2, 0), + ("IS", 3, 0), + ("PUSH", 4, 0), + ("AND", 5, 0), + ("STOP", 6, 0), + ], + ); + + assert_eq!( + b.rules(), + vec![ + "BABA IS PUSH", + "BABA IS STOP", + "ROCK IS PUSH", + "ROCK IS STOP", + "TEXT IS PUSH" + ] + ); +} + +#[test] +fn test_06_stacked_text_all_combinations() { + let mut b = new_baba(empty_level(3, 1)); + write( + &mut b, + &[("BABA", 0, 0), ("ROCK", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)], + ); + + assert_eq!( + b.rules(), + vec!["BABA IS YOU", "ROCK IS YOU", "TEXT IS PUSH"] + ); +} + +#[test] +fn test_06_default_text_is_push() { + let b = new_baba(LevelDef { + width: 3, + height: 3, + objects: vec![object("baba", 1, 1)], + ..Default::default() + }); + + assert_eq!(b.rules(), vec!["TEXT IS PUSH"]); +} diff --git a/course/06-parser/tests/ts/test_06.test.ts b/course/06-parser/tests/ts/test_06.test.ts new file mode 100644 index 0000000..758df00 --- /dev/null +++ b/course/06-parser/tests/ts/test_06.test.ts @@ -0,0 +1,78 @@ +// Module 6: Grid, lexer and parser. Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newBaba, type Baba } from "../src/harness"; + +/** An empty level `width` wide and `height` high. */ +const emptyLevel = (width: number, height: number) => ({ width, height, objects: [] }); + +/** Place one text object per word: `[word, x, y]`. */ +function write(b: Baba, words: [string, number, number][]): void { + for (const [word, x, y] of words) b.place(`text:${word}`, x, y); +} + +test("test_06_horizontal_rule", () => { + const b = newBaba({ + width: 5, + height: 3, + objects: [ + { kind: "text:BABA", x: 0, y: 0 }, + { kind: "text:IS", x: 1, y: 0 }, + { kind: "text:YOU", x: 2, y: 0 }, + { kind: "baba", x: 3, y: 2 }, + { kind: "text:YOU", x: 0, y: 2 }, + { kind: "text:IS", x: 1, y: 2 }, + { kind: "text:FLAG", x: 2, y: 2 }, + ], + }); + + expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH"]); +}); + +test("test_06_crossword_shared_is", () => { + const b = newBaba(emptyLevel(3, 3)); + write(b, [ + ["BABA", 0, 1], + ["IS", 1, 1], + ["YOU", 2, 1], + ["FLAG", 1, 0], + ]); + + expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH"]); + + b.place("text:WIN", 1, 2); + + expect(b.rules()).toEqual(["BABA IS YOU", "FLAG IS WIN", "TEXT IS PUSH"]); +}); + +test("test_06_and_distributes", () => { + const b = newBaba(emptyLevel(7, 1)); + write(b, [ + ["BABA", 0, 0], + ["AND", 1, 0], + ["ROCK", 2, 0], + ["IS", 3, 0], + ["PUSH", 4, 0], + ["AND", 5, 0], + ["STOP", 6, 0], + ]); + + expect(b.rules()).toEqual(["BABA IS PUSH", "BABA IS STOP", "ROCK IS PUSH", "ROCK IS STOP", "TEXT IS PUSH"]); +}); + +test("test_06_stacked_text_all_combinations", () => { + const b = newBaba(emptyLevel(3, 1)); + write(b, [ + ["BABA", 0, 0], + ["ROCK", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + + expect(b.rules()).toEqual(["BABA IS YOU", "ROCK IS YOU", "TEXT IS PUSH"]); +}); + +test("test_06_default_text_is_push", () => { + const b = newBaba({ width: 3, height: 3, objects: [{ kind: "baba", x: 1, y: 1 }] }); + + expect(b.rules()).toEqual(["TEXT IS PUSH"]); +}); diff --git a/course/harness.md b/course/harness.md index a7a094c..aaae768 100644 --- a/course/harness.md +++ b/course/harness.md @@ -68,7 +68,7 @@ In TypeScript each event is a plain object with a `type` field, for example `{ t ## Part II (`Baba`): additions per module -- M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, includes `"TEXT IS PUSH"`). +- M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, each distinct rule once, includes `"TEXT IS PUSH"`). - M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass). - M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted). - M9: `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain). @@ -90,6 +90,27 @@ Grid coordinates: `x` grows right, `y` grows down; vertical sentences read top-t | M9 | `step(dir)` | `step(&mut self, dir: Dir)` | | M9 | `won()`, `lost()` | `won(&self) -> bool`, `lost(&self) -> bool` | +`LevelDef` is the level format of `course/schema/level.schema.json`. In TypeScript it is `{ width, height, objects: { kind, x, y }[] }`. In Rust it is defined in `src/harness.rs` (or re-exported there) and implements `Default`, so tests write `LevelDef { width: 3, height: 3, ..Default::default() }` and keep compiling when Part III adds `cards`: + +```rust +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct LevelDef { + pub width: i32, + pub height: i32, + #[serde(default)] + pub objects: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct LevelObject { + pub kind: String, + pub x: i32, + pub y: i32, +} +``` + +`place` returns an `Id`, as in Part I; a `Baba` numbers its objects on its own. `rules()` is a set: a rule spelled twice on the board (stacked text can do that) appears once, and no test counts duplicates. + ## Part III (`SpellGrid`) - M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast`, `health`, `log`, `hero`, `won`. Minions are grid objects whose noun is their card's `"noun"` field.