diff --git a/course/10-spellgrid/expansion/README.md b/course/10-spellgrid/expansion/README.md new file mode 100644 index 0000000..980020d --- /dev/null +++ b/course/10-spellgrid/expansion/README.md @@ -0,0 +1,26 @@ +# Module 10 Expansion Drop: BATTLECRY IS DOUBLE + +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: `BATTLECRY`, a noun that names a trigger, as `DEATHRATTLE` does. With it, `BATTLECRY IS DOUBLE` written on the grid makes every Battlecry trigger twice, like Brann Bronzebeard ("Your Battlecries trigger twice.", Module 4). +- Card: Town Crier, a SpellGrid card that is already in `course/cards/` (`"game": "spellgrid"`): a 1-cost 1/1 minion, noun `CRIER`, "Battlecry: Deal 1 damage to the enemy hero." +- Tests: `tests/rust/test_10x.rs` and `tests/ts/test_10x.test.ts`. Copy the one for your track into your workspace's `tests/`. +- Reference answer, two data files and no code: + - `solution/words.json`: the Module 10 reference's `src/content/words.json` with one entry added, `{ "name": "BATTLECRY", "role": "noun" }`. + - `solution/seams.json`: the Module 10 reference's `src/content/seams.json` with one entry added to `triggerWords`, `"BATTLECRY": "battlecry"`. + - Nothing under `src/engine/` changes, and neither does the harness. The seam compiles any trigger word joined to any multiplier word; Module 4's one `activations` routine already multiplies Battlecries. +- 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_10x_battlecry_is_double + +- Setup: `newSpellGrid` with a level 5 wide and 3 high holding `BATTLECRY (0,0) IS (1,0) DOUBLE (2,0)`, no cards on the grid, and hand `["Town Crier"]`. +- Assertions 1: `activeRules()` contains `"BATTLECRY IS DOUBLE"`. +- Action: `clearLog()`, then `play("Town Crier", 0, 2)`, returning `crier`. +- Assertions 2: + - The log holds exactly two `Triggered { source: crier, trigger: "battlecry" }`. + - `health(hero(1))` is 28: two Battlecries of 1 damage each. + - `kindsAt(0,2)` is `["crier"]`: the minion entered the grid as its card's noun. + +Before the drop the lexicon doesn't know `BATTLECRY`, so the text spells no rule, and the first assertion fails. (Without that assertion the Battlecry would trigger once and the hero would have 29 Health.) diff --git a/course/10-spellgrid/expansion/solution/seams.json b/course/10-spellgrid/expansion/solution/seams.json new file mode 100644 index 0000000..075a6d5 --- /dev/null +++ b/course/10-spellgrid/expansion/solution/seams.json @@ -0,0 +1,4 @@ +{ + "triggerWords": { "DEATHRATTLE": "deathrattle", "BATTLECRY": "battlecry" }, + "multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } } +} diff --git a/course/10-spellgrid/expansion/solution/words.json b/course/10-spellgrid/expansion/solution/words.json new file mode 100644 index 0000000..e147957 --- /dev/null +++ b/course/10-spellgrid/expansion/solution/words.json @@ -0,0 +1,32 @@ +[ + { "name": "BABA", "role": "noun" }, + { "name": "ROCK", "role": "noun" }, + { "name": "WALL", "role": "noun" }, + { "name": "FLAG", "role": "noun" }, + { "name": "LAVA", "role": "noun" }, + { "name": "KEY", "role": "noun" }, + { "name": "DOOR", "role": "noun" }, + { "name": "SKULL", "role": "noun" }, + { "name": "WATER", "role": "noun" }, + { "name": "KEG", "role": "noun" }, + { "name": "BRAMBLE", "role": "noun" }, + { "name": "HEDGE", "role": "noun" }, + { "name": "WARDEN", "role": "noun" }, + { "name": "MOTH", "role": "noun" }, + { "name": "SCRIBE", "role": "noun" }, + { "name": "KNIGHT", "role": "noun" }, + { "name": "DEATHRATTLE", "role": "noun" }, + { "name": "BATTLECRY", "role": "noun" }, + { "name": "YOU", "role": "property" }, + { "name": "WIN", "role": "property" }, + { "name": "PUSH", "role": "property" }, + { "name": "STOP", "role": "property" }, + { "name": "OPEN", "role": "property" }, + { "name": "SHUT", "role": "property" }, + { "name": "HOT", "role": "property" }, + { "name": "MELT", "role": "property" }, + { "name": "SINK", "role": "property" }, + { "name": "DEFEAT", "role": "property" }, + { "name": "FLOAT", "role": "property" }, + { "name": "DOUBLE", "role": "property" } +] diff --git a/course/10-spellgrid/expansion/tests/rust/test_10x.rs b/course/10-spellgrid/expansion/tests/rust/test_10x.rs new file mode 100644 index 0000000..af152c7 --- /dev/null +++ b/course/10-spellgrid/expansion/tests/rust/test_10x.rs @@ -0,0 +1,43 @@ +// Module 10 Expansion Drop: BATTLECRY IS DOUBLE. The scenario is in ../../README.md. +use engine::harness::*; + +#[test] +fn test_10x_battlecry_is_double() { + let words = [("BATTLECRY", 0), ("IS", 1), ("DOUBLE", 2)]; + let mut g = new_spell_grid( + LevelDef { + width: 5, + height: 3, + objects: words + .iter() + .map(|&(w, x)| LevelObject { + kind: format!("text:{w}"), + x, + y: 0, + }) + .collect(), + cards: vec![], + hand: vec!["Town Crier".to_string()], + }, + vec![], + ); + + assert!(g.active_rules().iter().any(|r| r == "BATTLECRY IS DOUBLE")); + + g.clear_log(); + let crier = g.play("Town Crier", 0, 2); + + let battlecries = g + .log() + .into_iter() + .filter(|e| { + *e == GameEvent::Triggered { + source: crier, + trigger: "battlecry".to_string(), + } + }) + .count(); + assert_eq!(battlecries, 2); + assert_eq!(g.health(g.hero(1)), 28); + assert_eq!(g.kinds_at(0, 2), vec!["crier"]); +} diff --git a/course/10-spellgrid/expansion/tests/ts/test_10x.test.ts b/course/10-spellgrid/expansion/tests/ts/test_10x.test.ts new file mode 100644 index 0000000..b6327cf --- /dev/null +++ b/course/10-spellgrid/expansion/tests/ts/test_10x.test.ts @@ -0,0 +1,30 @@ +// Module 10 Expansion Drop: BATTLECRY IS DOUBLE. The scenario is in ../../README.md. +import { expect, test } from "vitest"; +import { newSpellGrid } from "../src/harness"; + +test("test_10x_battlecry_is_double", () => { + const g = newSpellGrid( + { + width: 5, + height: 3, + objects: [ + { kind: "text:BATTLECRY", x: 0, y: 0 }, + { kind: "text:IS", x: 1, y: 0 }, + { kind: "text:DOUBLE", x: 2, y: 0 }, + ], + cards: [], + hand: ["Town Crier"], + }, + [], + ); + + expect(g.activeRules()).toContain("BATTLECRY IS DOUBLE"); + + g.clearLog(); + const crier = g.play("Town Crier", 0, 2); + + const battlecries = g.log().filter((e) => e.type === "Triggered" && e.source === crier && e.trigger === "battlecry"); + expect(battlecries).toHaveLength(2); + expect(g.health(g.hero(1))).toBe(28); + expect(g.kindsAt(0, 2)).toEqual(["crier"]); +}); diff --git a/course/10-spellgrid/lesson.md b/course/10-spellgrid/lesson.md new file mode 100644 index 0000000..96ada4b --- /dev/null +++ b/course/10-spellgrid/lesson.md @@ -0,0 +1,399 @@ +# Module 10: SpellGrid + +You now have two engines. Part I's resolves cards: a Sequence of actions, triggers queued and resolved depth-first, deaths held back until the Death Creation Step, auras read from a snapshot, and cards as data. Part II's resolves a grid: words become rules, the rules are recompiled whenever the board changes, and a turn is a fixed list of phases. This module puts both in one small game of our own, **SpellGrid**, where minions with Health and Deathrattles stand on a grid next to word blocks. + +The point is not a bigger engine. It is how little the two engines need to know about each other. SpellGrid joins them at three seams and nowhere else, and every test in this module checks one seam, or a level that needs all three. + +SpellGrid is the course's game, not Blizzard's or Hempuli's. Its cards are SpellGrid originals, marked `"game": "spellgrid"` in `course/catalog.json`, and its rules are the course's choices. Where this lesson says what Hearthstone or Baba Is You does, it cites the source an earlier module already used. + +You need your Module 9 workspace with all Module 1–9 tests passing, under git. + +## Observe + +Two rules from two games, both met before. + +From Part I. Baron Rivendare: "Your minions trigger their Deathrattles twice." ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)). Module 4 built the machinery that makes that true, from the [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): + +- "Baron Rivendare's effect, being an aura, only appears and disappears between Phases when Aura Update (Other) runs". +- When the outermost Phase ends, "Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows the step. +- "Cards that trigger effects multiple times, like Brann Bronzebeard, Baron Rivendare and Drakkari Enchanter, check before an Event starts." + +So in Module 4's Twisting Nether case, Baron and a Loot Hoarder destroyed together leave play in the same Death Creation Step, the Aura Update after it finds no Baron, and the Hoarder's Deathrattle resolves once. + +From Part II. Module 7's `NOT`, from the Baba Is You Wiki: + +- "ROCK IS NOT PUSH makes ROCK NOT PUSHable, even if ROCK IS PUSH exists elsewhere." ([NOT](https://babaiswiki.fandom.com/wiki/NOT)) +- "BABA IS NOT YOU prevents BABA from having the property YOU, overriding any BABA IS YOU rule." ([IS](https://babaiswiki.fandom.com/wiki/IS)) + +And Module 9's turn, from [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): "Deletion occurs after each step that removes objects, but all objects destroyed in the same step are destroyed simultaneously." 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." + +Now the fused game. Nothing below is from a real game; it is what SpellGrid does, and what this module's tests check. + +1. A grid holds the words `DEATHRATTLE IS DOUBLE`, and a Powder Keg (1/1, noun `KEG`, "Deathrattle: Deal 1 damage to all minions.") stands on it next to a Bramble Wall (0/3, noun `BRAMBLE`). You cast Whirlwind. The keg dies, and its Deathrattle goes off **twice**: the words on the grid did what Baron does. +2. The words `KEG IS YOU AND MELT` and `LAVA IS HOT`. You walk the keg onto the lava. It melts, but it doesn't just vanish from the grid: it dies in Part I's Death Creation Step, and its Deathrattle hits the bramble. +3. A Rune Scribe ("Deathrattle: Write WIN on this tile.") stands on the empty end of `BABA IS`. It dies. `WIN` appears where it stood, and `BABA IS WIN` is a rule before anything else happens. + +## Predict + +Write your answers down before you open the discussion, for example in `notes/10.md` in your workspace. + +1. `DEATHRATTLE IS DOUBLE` is on the grid, and a Grave Warden is in play: a SpellGrid card with Baron's text, "Your minions trigger their Deathrattles twice." A Powder Keg dies. How many times does its Deathrattle trigger: two, three or four? +2. Now `DEATHRATTLE IS NOT DOUBLE` is on the grid too, and the Warden is still in play. How many times? +3. A Grave Warden (1 Health) and a Powder Keg (1 Health) die to the same Whirlwind. A Tin Knight with 3 Health is `YOU`. Does the knight survive the turn? +4. The scribe from Observe dies because a turn melted it in lava. `BABA IS YOU` is on the board. Is the level won in that same turn, or the next one? +5. Which of the two engines should know that the word `DEATHRATTLE` means Deathrattles? + +
Discussion + +1. **Two.** The rule's multiplier and the Warden's are the same kind of data, a trigger multiplier with `stacking: "max"`, so together they double and no more. Module 4 needed `stacking` because Hearthstone's doublers don't stack ("max") while the course's Test Chorus does ("additive"). SpellGrid's choice is that a rule on the grid is one more doubler like Baron, so it takes "max" too. Had it been "additive", the Warden and the rule would give three activations (1 + 1 + 1), not four: the course's additive stacking adds each multiplier's extra activations. +2. **Once.** In Module 7, `X IS NOT Y` overrides `X IS Y` wherever it is written. SpellGrid gives the negated rule the same reach: `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them, card or rule. The rule `DEATHRATTLE IS DOUBLE` is cancelled by Module 7's resolution pass and never becomes a modifier; the Warden's aura is cancelled by the cap. Both are SpellGrid's choices, not a real game's. +3. **Yes, with 1 Health.** Whirlwind deals 1 to everything: the knight goes to 2, the keg and the Warden to 0. They leave play in the same Death Creation Step. The Aura Update runs after the step, the Warden's aura is gone, and the keg's Deathrattle deals 1 once: the knight goes to 1. This is Module 4's Twisting Nether case with damage instead of a draw. An engine that reads the auras before the dead leave play doubles the Deathrattle, and the knight dies. Level 3 is built on that. +4. **The same turn.** The scribe's death is a Sequence inside the turn's hazards phase. When the Sequence ends, `WIN` is written and the rules are reparsed, before the next hazard and before the win check. An engine that waits for the turn's own parse finds `BABA IS WIN` only after the win check, and wins a turn late. +5. **Neither.** Part I knows triggers; Part II knows words. A table in content says that the noun `DEATHRATTLE` names the trigger `deathrattle` and that the property `DOUBLE` means "twice, `max`". The code between the engines reads the table. The Expansion Drop adds `BATTLECRY` with two lines of data. + +
+ +## Spec + +From the course repository, copy the module's tests and levels into your workspace: + +```sh +node tools/start-module.mjs 10 ~/my-engine +``` + +It copies `tests/test_10.*` and the three levels into `levels/`. The tests load them from there. + +### The harness + +One constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`. It has every Part II function (`place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`), and: + +| TypeScript | Rust | +|---|---| +| `cast(card, target?): void` | `cast(&mut self, card: &str, target: Option)` | +| `play(card, x, y): Id` | `play(&mut self, card: &str, x: i32, y: i32) -> Id` | +| `health(id)`, `hero(player)`, `nameOf(id)` | `health`, `hero`, `name_of` | +| `minionAt(x, y): Id \| null` | `minion_at(&self, x: i32, y: i32) -> Option` | +| `hand(): string[]` | `hand(&self) -> Vec` | +| `log()`, `clearLog()` | `log`, `clear_log` | +| `setChooser(choose)` | `set_chooser(...)` | +| `withNaiveAuraTiming()` | `with_naive_aura_timing(&mut self)` | + +- **The level format** gains `cards: [{ card, x, y }]` and `hand: [card]` (`course/schema/level.schema.json`). Each entry of `cards` is a minion in play for player 0 *and* a grid object at (x, y), whose kind is its card's `"noun"` in lower case: Powder Keg has `"noun": "KEG"`, so it stands on the grid as a `keg`, and `KEG IS YOU` moves it. +- **One id space.** A minion's id is its grid object's id. `posOf(keg)`, `hasProperty(keg, "MELT")` and `health(keg)` take the same id, and `log()` reports it. A hero or a card in hand gets an id no grid object has. +- **`cast(card, target?)`** casts a spell from player 0's hand, as one Sequence; `target` is a minion's id. **`play(card, x, y)`** plays a minion from the hand onto an empty tile, as one Sequence (Module 4's `play`: it enters the board, then its Battlecry), and returns its id. Both are errors for a card that isn't in the hand. +- **Casting is not a turn.** `won()` only changes in `step`. +- **`withNaiveAuraTiming()`** is a test hook for one test: it swaps the two steps at the end of the outermost Phase, so the Aura Update runs before the Death Creation Step. + +The three seams: + +1. **Rules become modifiers.** A rule in force `DEATHRATTLE IS DOUBLE` is a trigger multiplier like Baron's (`times: 2`, `stacking: "max"`), for both players' minions. `DEATHRATTLE IS NOT DOUBLE` caps Deathrattles at one activation, whatever multiplies them. A rule takes effect at the next Aura Update, like a minion's aura. +2. **Hazards mark, the death step removes.** A minion that a turn destroys (a hazard, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy, in one Sequence, and Module 3's Death Creation Step removes it. Its Deathrattle resolves. +3. **Grid changes reparse.** When a Sequence ends, the text its effects asked for is written (`spawnText`: "write the word on this minion's tile"), the minions that died leave the grid, and the rules are parsed again, before anything else. + +SpellGrid's cards, all in `course/cards/`: + +| Card | Type | Stats | Noun | Text | +|---|---|---|---|---| +| Powder Keg | minion, 2 | 1/1 | KEG | Deathrattle: Deal 1 damage to all minions. | +| Bramble Wall | minion, 1 | 0/3 | BRAMBLE | | +| Thorn Hedge | minion, 1 | 0/2 | HEDGE | | +| Grave Warden | minion, 3 | 1/1 | WARDEN | Your minions trigger their Deathrattles twice. | +| Ember Moth | minion, 1 | 1/1 | MOTH | Taunt. Deathrattle: Deal 2 damage to the enemy hero. | +| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. | +| Tin Knight | minion, 2 | 1/3 | KNIGHT | | +| Town Crier | minion, 1 | 1/1 | CRIER | Battlecry: Deal 1 damage to the enemy hero. (The Expansion Drop's.) | +| Spark | spell, 1 | | | Deal 1 damage to a minion. | +| Graft | spell, 2 | | | Assemble a Deathrattle minion. | + +Whirlwind is Module 3's Hearthstone card. New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT`, `DEATHRATTLE`, and the property `DOUBLE`. + +> **How the real game differs** +> - SpellGrid is not a real game, and its cards are not Hearthstone cards. Grave Warden is Baron Rivendare's text on a 1-Health body, so that it dies to the same Whirlwind as the minion it would double. Baron is 1/7 ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)). +> - Graft is Fleshshape's Assemble without the buff ([Fleshshape](https://hearthstone.wiki.gg/wiki/Fleshshape): "Assemble a Deathrattle minion. Give all minions in your hand +2/+2."). As in Module 5, the chooser makes every choice that Hearthstone makes at random, and the pool is every Deathrattle minion in `cards/`, not Hearthstone's curated pool ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)). +> - Baba Is You has no `DEATHRATTLE` or `DOUBLE`. They are SpellGrid's words. + +### The tests + +The full statements are in `course/10-spellgrid/scenarios.md`. + +- `test_10_spatial_rule_feeds_interceptor`: seam 1. Placing `DOUBLE` completes `DEATHRATTLE IS DOUBLE`; a Spark on the keg then gives two Deathrattles. +- `test_10_not_cancels_spatial_modifier`: Predict question 2. One Deathrattle, with the Warden in play. +- `test_10_grid_and_card_doublers_take_max`: Predict question 1. Two, not four. +- `test_10_hazard_feeds_death_step`: seam 2. The log is exactly `Destroyed`, `DeathPhase`, `Triggered`, `Damaged`. +- `test_10_spawned_text_triggers_reparse`: seam 3. `BABA IS WIN` is a rule straight after the cast. +- `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution`: each level, played from a script to `won()`. +- `test_10_level_3_naive_would_differ`: level 3 with `withNaiveAuraTiming()`. Same inputs, and the knight dies. + +### The levels + +Three puzzles in `course/10-spellgrid/levels/`. Capitals are text, lower case are objects, `#` is a wall. You can play each one (see [Play it](#play-it)). + +**Level 1, "Double-Tap".** The bramble (3 Health, `STOP`) is the only way to the flag. Whirlwind does 1 and the keg's Deathrattle 1 more: one short. Push `DOUBLE` up through the gap to finish `DEATHRATTLE IS DOUBLE` first. Hand: Whirlwind. + +```text +BABA IS YOU FLAG IS WIN WALL IS STOP +BRAMBLE IS STOP DEATHRATTLE IS . . . . +# # # # # . # # # +. . . . . DOUBLE . . . +keg . . . . baba . . . +. . . . . # # # # +. . . . . bramble flag # # +``` + +**Level 2, "Spliced Corpse".** `BABA IS` ends on a lava tile, and `MOTH IS PUSH AND MELT` is written, but there is no moth. Graft one: a moth's noun with a Deathrattle that writes `WIN`. Play it, and push it into the lava. Hand: Graft. + +```text +BABA IS YOU . LAVA IS HOT +MOTH IS PUSH AND MELT . . +WALL IS STOP . . . . +BABA IS lava . . baba . +. . . . . . . +. . . . . . . +``` + +**Level 3, "Simultaneous Paradox".** You are the Tin Knight (3 Health). The hedge (2 Health, `STOP`) blocks the flag. The keg and the Grave Warden are walled off. One Whirlwind is the whole solution, if your engine reads the auras at the right moment. Hand: Whirlwind. + +```text +KNIGHT IS YOU . FLAG IS WIN +WALL IS STOP . HEDGE IS STOP +# # # # # # # +. . . # # # # +knight . . . hedge flag # +. keg warden . # # # +``` + +## Build + +Make the nine tests pass, and keep Modules 1–9 green. The shape to aim for: + +```text + Part I: the card engine Part II: the grid engine + +-----------------------------+ +------------------------------+ + | World, Sequence, Death | | Board, Lexicon, parse, | + | Creation Step, auras, | | resolve, TURN, hazards | + | activations | | | + +-----------------------------+ +------------------------------+ + ^ | ^ | ^ ^ + (1) | (3) | events | (2) destroy | rules | (2) | (3) write text, + auras | v | Sequence v in force| victims | remove the dead, + +----------------------------------------------------------------------------+ + | SpellGrid (the only code that knows both) | + | (1) rules in force --> rule auras --> Aura Update | + | (2) a turn's victims --> minions: one Destroy Sequence; others: removed | + | (3) Sequence ends --> TextSpawned: write; Destroyed: remove; reparse | + +----------------------------------------------------------------------------+ +``` + +- **Neither engine imports the other.** Part I never names a tile; Part II never names a card or a Health. Everything that crosses goes through the code in the middle, and only in the three ways in the diagram. If you catch yourself adding `if this object is a minion` to the grid's hazards, or `where is this minion on the grid` to the card engine, the seam is in the wrong place. +- **Seam 1: rules are aura data.** After every reparse, read the rules in force. Each rule whose subject is a trigger word and whose complement is a multiplier word becomes an aura, the same data as Baron's. Hand them to Part I as a list of auras that no minion carries, and let the next Aura Update put them in the snapshot beside the minions' auras. `activations` stays the one routine that counts. +- **Negation is a cap.** A negated rule can't be cancelled by resolution, because it *is* the canceller. Make it a modifier of its own kind that limits the count, and let `activations` apply it last. +- **Which words are triggers is data.** Put the table (`DEATHRATTLE` → `deathrattle`, `DOUBLE` → twice, `max`) in content. +- **Seam 2: a destroy hook.** Module 9's turn removes whatever movement, conversion and hazards destroy. Give the turn a function to call with the victims instead, with "remove them" as the default. SpellGrid's function removes text and things, and sends minions to Part I as one Sequence that marks them all pending destroy. That keeps Module 9's "all objects destroyed in the same step are destroyed simultaneously": they share one Death Creation Step. +- **Seam 3: freeze the grid during a Sequence.** Part I has no grid, so `spawnText` can't write one. It records an event, `TextSpawned`, and the code in the middle reads the Sequence's events when it ends: write each word on its minion's tile (the minion is still on the grid, because nothing leaves the grid mid-Sequence), then remove each minion with a `Destroyed` event, then reparse. Reparsing here, inside the turn, is what makes level 2 win in the same step. +- **The test hook is not engine content.** Module 9 made the turn a list of phases. Do the same for the end of the outermost Phase: a list `[Death Creation Step, Aura Update]`, read from the world. The harness hook sets the reversed list. The engine has no "naive" branch; the order is data, and only a test changes it. + +
Hint 1 — nudge + +Write the code in the middle as one file that owns a world and a level, and keeps a map from grid ids to entities. Then write three small functions, one per seam, and call them from `place`, `step`, `cast` and `play`. Start with seam 1: `test_10_spatial_rule_feeds_interceptor` needs nothing else. + +
+ +
Hint 2 — concept + +- **Ids.** Create the minion in Part I and the object in Part II together, and record both ways: grid id → entity, entity → grid id. Give every other entity (heroes, hand cards) an id from the board's counter that no object will have, so one number never means two things. Translate the log's entity ids into grid ids in the harness. +- **Seam 1.** `ruleAuras(activeRules, table)`: for each rule, look up the subject in the trigger table and the complement in the multiplier table; positive gives `triggerMultiplier`, negated gives `triggerCap` with `times: 1`. Store the list in the world, run an Aura Update. In `activations`, collect the caps per trigger and return `min(cap, max + extra)`. +- **Seam 2.** `Level.step(dir, destroy)`. In SpellGrid's `destroy`, split the ids: not a card → remove now; a card → collect. If any cards were collected, bring the rule auras up to date (this turn's movement may have changed the rules), then run one `destroy` Sequence for all of them. +- **Seam 3.** Note the log's length, run the Sequence, take the new events. `TextSpawned { source, word }` → place `text:WORD` on the source's tile. `Destroyed { id }` → remove its object. Then reparse, then seam 1 again. +- **Playing a card.** `play(card, x, y)` places the object first, then runs Module 4's `play` action: a Battlecry that writes text needs a tile. + +
+ +
Hint 3 — pseudo-code + +```text +ruleAuras(rules, table): + auras = [] + for r in rules: + trigger = table.triggerWords[r.subject]; mult = table.multiplierWords[r.complement] + if trigger and mult: + auras += r.negated ? cap(trigger, 1) : multiplier(trigger, mult.times, mult.stacking) + return auras + +activations(owner, on): + max = 1; extra = 0; cap = infinity + for a in snapshot where a.owner == owner and a.trigger == on: + cap: cap = min(cap, a.times) + max-stacking multiplier: max = max(max, a.times) + additive multiplier: extra += a.times - 1 + return min(cap, max + extra) + +END_OF_PHASE = [deathCreation, auraUpdate] // the harness hook reverses it + +destroy(level, ids): // seam 2, handed to level.step + for id in distinct ids still on the board: + if id is a minion: minions += entity(id) else: level.board.remove(id) + if minions: syncRules(); sequence(Destroy(minions)) + +sequence(action): // seam 3 + from = log.length; runSequence(world, action) + for e in log[from..]: if e is TextSpawned: place text:e.word on tile of e.source + for e in log[from..]: if e is Destroyed: remove object of e.id + level.reparse(); syncRules() + +syncRules(): // seam 1 + world.setRuleAuras(ruleAuras(level.activeRules(), table)); auraUpdateOther(world) +``` + +
+ +### Play it + +The reference ships a command line, kept thin over the harness. From a workspace with `cards/` and `levels/`: + +```sh +cargo run --bin spellgrid -- levels/l1-double-tap.json # Rust +npx tsx src/cli.ts levels/l1-double-tap.json # TypeScript +``` + +`w`, `a`, `s`, `d` play a turn; `c [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. Writing your own is optional: the course's tests don't call it. + +## Name it + +The pattern is an **integration seam**: two subsystems that each keep their own model, joined by a narrow, explicit interface that translates between the models, in one place. Each seam carries one kind of thing, in one direction, at one moment. Rules go from the grid to the cards as aura data, at an Aura Update. Victims go from the grid to the cards as one destroy Sequence, during the turn's phase. Grid changes go from the cards to the grid as events, when a Sequence ends. + +Two earlier patterns do most of the work. The **event log** from Module 2 is what lets Part I ask for a grid change without having a grid: it records `TextSpawned` and the seam reads it later. Robert Nystrom's description of an event queue fits: "This decouples the sender from the receiver both statically and in time." ([Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html)). And the **interceptor** from Module 4, one routine that counts activations from data, is what lets a rule on the grid double a Deathrattle without a line of new resolution code. The rule is compiled into the same data Baron is: + +```rust +/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a +/// multiplier word) multiplies T's triggers; `T IS NOT M` caps them at one activation, +/// so it cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`. +pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec { + let mut auras = Vec::new(); + for rule in rules { + let (Some(&trigger), Some(&m)) = ( + words.trigger_words.get(&rule.subject), + words.multiplier_words.get(&rule.complement), + ) else { + continue; + }; + auras.push(if rule.negated { + Aura::TriggerCap { trigger, times: 1 } + } else { + Aura::TriggerMultiplier { + trigger, + times: m.times, + stacking: m.stacking, + } + }); + } + auras +} +``` + +```ts +/** + * Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a multiplier + * word) multiplies T's triggers; `T IS NOT M` caps them at one activation, so it + * cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`. + */ +export function ruleAuras(rules: readonly puzzle.Rule[], words: SeamWords): Aura[] { + const auras: Aura[] = []; + for (const rule of rules) { + const trigger = words.triggerWords[rule.subject]; + const multiplier = words.multiplierWords[rule.complement]; + if (trigger === undefined || multiplier === undefined) continue; + auras.push( + rule.negated + ? { type: "triggerCap", trigger, times: 1 } + : { type: "triggerMultiplier", trigger, ...multiplier }, + ); + } + return auras; +} +``` + +The question this module had to settle: **how does a rule on the grid combine with a card?** The reference's answer is that a grid modifier is data of the same kind, with the same `stacking` field, so the rules for combining are Module 4's and nothing new. `DOUBLE` is `stacking: "max"` because it reads like Baron, and two Barons don't quadruple. That makes Warden + `DEATHRATTLE IS DOUBLE` two activations, not four. A word with `"additive"` in the table would add instead; the engine wouldn't change. Negation is the one new piece of data, a cap, because `NOT` in Module 7 overrides wherever it is written, and the card side had no way to say "never more than once". + +Level 3's trap is the end-of-Phase order, and the reference writes that order down as data, like Module 9's turn: + +```rust +/// The end of the outermost Phase, in Hearthstone's order: the dead leave play first, +/// then the auras are read again, so a minion that just died multiplies nothing in +/// the Death Phase that follows. +pub const END_OF_PHASE: [EndOfPhaseStep; 2] = + [EndOfPhaseStep::DeathCreation, EndOfPhaseStep::AuraUpdate]; +``` + +```ts +/** + * The end of the outermost Phase, in Hearthstone's order: the dead leave play first, + * then the auras are read again, so a minion that just died multiplies nothing in + * the Death Phase that follows. + */ +export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"]; +``` + +`withNaiveAuraTiming()` in the harness sets the same two steps reversed. The naive engine is not a branch in the engine; it is one different list, and the test shows what that list costs: the knight dies. + +> **How the real game differs** +> - Hearthstone has no grid, and Baba Is You has no Health or Deathrattles. Everything about how they combine is SpellGrid's design. +> - Hearthstone's end of the outermost Phase has an Aura Update (Health/Attack) before the Death Creation Step ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no Health/Attack auras, so `END_OF_PHASE` leaves it out, as Module 4 did. +> - Baba Is You reparses "when any parsable object changes position or is destroyed" ([Rule](https://babaiswiki.fandom.com/wiki/Rule)). SpellGrid reparses after each Sequence whether or not it changed the grid. That is cheaper to reason about and gives the same rules. +> - A minion that a conversion turns into something else (`KEG IS ROCK`) keeps its card in the reference: the object changes kind, the minion stays in play. No test or level does this, and a real design would have to decide. +> - A minion that a card effect summons (Harvest Golem's Damaged Golem) enters play but not the grid. No SpellGrid card summons. + +## Expansion Drop + +A new trigger word: `BATTLECRY`. With it, `BATTLECRY IS DOUBLE` on the grid does what Brann Bronzebeard's "Your Battlecries trigger twice." does ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)), the Module 4 Expansion Drop. The test plays a Town Crier ("Battlecry: Deal 1 damage to the enemy hero.", already in `course/cards/`) under the rule. + +The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. + +1. Commit your workspace and note the commit: `git add -A && git commit -m "module 10 done"`. +2. Copy the expansion test for your track from `course/10-spellgrid/expansion/tests/rust/test_10x.rs` or `course/10-spellgrid/expansion/tests/ts/test_10x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Teach SpellGrid the word. +4. When the tests pass, run the check from the course repository: + +```sh +node tools/expansion-check.mjs ~/my-engine HEAD +``` + +The full scenario, `test_10x_battlecry_is_double`, is in `course/10-spellgrid/expansion/README.md`. + +
Discussion: what this drop measures + +If the seam reads a table, the drop is two lines of data and no code: `{ "name": "BATTLECRY", "role": "noun" }` in `words.json`, so the parser can read it, and `"BATTLECRY": "battlecry"` in `seams.json`, so the seam knows what it names. That is the reference answer, in `course/10-spellgrid/expansion/solution/`. The Battlecry is doubled by Module 4's `activations`, which has counted Battlecries since Brann. + +Shapes that fail this drop: + +- **`if rule == "DEATHRATTLE IS DOUBLE"` in the seam.** The seam then knows one rule, and `BATTLECRY` is a second branch in `src/engine/`. Make the words data, as a normal commit, then redo the drop. +- **The seam writes the count, not an aura.** If the grid rule sets a "Deathrattles fire twice" flag that the Deathrattle code reads, Battlecries need their own flag, in the engine. Compile rules into the same aura data cards use. +- **Minions played straight onto the grid, skipping Part I's `play`.** Then the Battlecry never triggers at all, rule or no rule. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Further reading + +- The Hearthstone Wiki's [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook), on Aura Updates and the Death Creation Step: the source of Level 3's answer, and of Module 4's. +- The Baba Is You Wiki's [NOT](https://babaiswiki.fandom.com/wiki/NOT), [Rule](https://babaiswiki.fandom.com/wiki/Rule) and [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations) pages: the other half of each seam. +- Robert Nystrom, [Game Programming Patterns: Event Queue](https://gameprogrammingpatterns.com/event-queue.html). Its warning, "you have to be careful not to assume the current state of the world reflects how the world was", is why SpellGrid writes a dead minion's text before it takes the minion off the grid. +- Celia Wagar, [Card Game Design as Systems Architecture](https://critpoints.net/2023/05/26/card-game-design-as-systems-architecture/): "Trigger conditions are lifecycle hooks for cards." Read it after this module, as a designer's view of the same course. + +### The last question + +Module 4 had Baron Rivendare modify Deathrattles. Module 7 had `BABA IS NOT YOU` modify `BABA IS YOU`. Both are one rule changing what another rule does. Compare them: where does each modifier live, when is it read, what does it change, and what happens when two of them disagree? Then say what SpellGrid had to decide to put them in one game. + +
Discussion + +Both are **rules about rules**, and both engines keep them out of the rule they modify. Baron's text doesn't touch the Deathrattle: Module 4's `activations` reads the aura snapshot when the queued Deathrattle starts and runs it twice. `BABA IS NOT YOU` doesn't touch `BABA IS YOU`: Module 7's resolution pass reads every parsed rule and drops the ones a negation cancels. In both, the modified rule is the same data as before, and one routine decides. + +The differences are where the design is: + +- **When the modifier is read.** Baron is read from a snapshot taken at fixed points (after a Death Creation Step, on a summon), so a Baron that dies mid-Phase still counts until the next update, and one that dies in the same step as the Deathrattle doesn't count. A Baba rule is read after a reparse, and the course reparses at fixed points in the turn. Both engines answer "when is the world looked at?" with a schedule, not with "whenever someone asks". Level 3 and Module 9's lava test are the same lesson, one per engine. +- **What it changes.** Baron changes *how many times* something happens: a number, combined with other numbers by `stacking`. `NOT` changes *whether* a rule is in force: a yes or no, and no wins. That is why, in one game, the grid's negation became a cap: a yes/no from Part II meeting a count in Part I. +- **Who wins a disagreement.** Two Barons don't stack (`max`); `X IS NOT Y` beats any number of `X IS Y`. Hearthstone has no "not doubled" card to disagree with Baron, so SpellGrid had to choose, and chose Baba's answer: negation beats every multiplier, from cards or from the grid. + +What SpellGrid had to decide: that a rule on the grid is the same kind of data as a card's aura (so `stacking` applies, and Warden + `DOUBLE` is two); that it enters the snapshot at the same moments as a card's (so it obeys the Aura Update schedule); and that `NOT` from the grid caps card multipliers too. None of those came from either game. Each is one line of data or one line in `activations`, and each is a test. + +
diff --git a/course/10-spellgrid/levels/l1-double-tap.json b/course/10-spellgrid/levels/l1-double-tap.json new file mode 100644 index 0000000..d804c76 --- /dev/null +++ b/course/10-spellgrid/levels/l1-double-tap.json @@ -0,0 +1,42 @@ +{ + "width": 9, + "height": 7, + "objects": [ + {"kind": "text:BABA", "x": 0, "y": 0}, + {"kind": "text:IS", "x": 1, "y": 0}, + {"kind": "text:YOU", "x": 2, "y": 0}, + {"kind": "text:FLAG", "x": 3, "y": 0}, + {"kind": "text:IS", "x": 4, "y": 0}, + {"kind": "text:WIN", "x": 5, "y": 0}, + {"kind": "text:WALL", "x": 6, "y": 0}, + {"kind": "text:IS", "x": 7, "y": 0}, + {"kind": "text:STOP", "x": 8, "y": 0}, + {"kind": "text:BRAMBLE", "x": 0, "y": 1}, + {"kind": "text:IS", "x": 1, "y": 1}, + {"kind": "text:STOP", "x": 2, "y": 1}, + {"kind": "text:DEATHRATTLE", "x": 3, "y": 1}, + {"kind": "text:IS", "x": 4, "y": 1}, + {"kind": "wall", "x": 0, "y": 2}, + {"kind": "wall", "x": 1, "y": 2}, + {"kind": "wall", "x": 2, "y": 2}, + {"kind": "wall", "x": 3, "y": 2}, + {"kind": "wall", "x": 4, "y": 2}, + {"kind": "wall", "x": 6, "y": 2}, + {"kind": "wall", "x": 7, "y": 2}, + {"kind": "wall", "x": 8, "y": 2}, + {"kind": "text:DOUBLE", "x": 5, "y": 3}, + {"kind": "baba", "x": 5, "y": 4}, + {"kind": "wall", "x": 5, "y": 5}, + {"kind": "wall", "x": 6, "y": 5}, + {"kind": "wall", "x": 7, "y": 5}, + {"kind": "wall", "x": 8, "y": 5}, + {"kind": "flag", "x": 6, "y": 6}, + {"kind": "wall", "x": 7, "y": 6}, + {"kind": "wall", "x": 8, "y": 6} + ], + "cards": [ + {"card": "Powder Keg", "x": 0, "y": 4}, + {"card": "Bramble Wall", "x": 5, "y": 6} + ], + "hand": ["Whirlwind"] +} diff --git a/course/10-spellgrid/levels/l2-spliced-corpse.json b/course/10-spellgrid/levels/l2-spliced-corpse.json new file mode 100644 index 0000000..3bc064e --- /dev/null +++ b/course/10-spellgrid/levels/l2-spliced-corpse.json @@ -0,0 +1,26 @@ +{ + "width": 7, + "height": 6, + "objects": [ + {"kind": "text:BABA", "x": 0, "y": 0}, + {"kind": "text:IS", "x": 1, "y": 0}, + {"kind": "text:YOU", "x": 2, "y": 0}, + {"kind": "text:LAVA", "x": 4, "y": 0}, + {"kind": "text:IS", "x": 5, "y": 0}, + {"kind": "text:HOT", "x": 6, "y": 0}, + {"kind": "text:MOTH", "x": 0, "y": 1}, + {"kind": "text:IS", "x": 1, "y": 1}, + {"kind": "text:PUSH", "x": 2, "y": 1}, + {"kind": "text:AND", "x": 3, "y": 1}, + {"kind": "text:MELT", "x": 4, "y": 1}, + {"kind": "text:WALL", "x": 0, "y": 2}, + {"kind": "text:IS", "x": 1, "y": 2}, + {"kind": "text:STOP", "x": 2, "y": 2}, + {"kind": "text:BABA", "x": 0, "y": 3}, + {"kind": "text:IS", "x": 1, "y": 3}, + {"kind": "lava", "x": 2, "y": 3}, + {"kind": "baba", "x": 5, "y": 3} + ], + "cards": [], + "hand": ["Graft"] +} diff --git a/course/10-spellgrid/levels/l3-simultaneous-paradox.json b/course/10-spellgrid/levels/l3-simultaneous-paradox.json new file mode 100644 index 0000000..f89fa73 --- /dev/null +++ b/course/10-spellgrid/levels/l3-simultaneous-paradox.json @@ -0,0 +1,41 @@ +{ + "width": 7, + "height": 6, + "objects": [ + {"kind": "text:KNIGHT", "x": 0, "y": 0}, + {"kind": "text:IS", "x": 1, "y": 0}, + {"kind": "text:YOU", "x": 2, "y": 0}, + {"kind": "text:FLAG", "x": 4, "y": 0}, + {"kind": "text:IS", "x": 5, "y": 0}, + {"kind": "text:WIN", "x": 6, "y": 0}, + {"kind": "text:WALL", "x": 0, "y": 1}, + {"kind": "text:IS", "x": 1, "y": 1}, + {"kind": "text:STOP", "x": 2, "y": 1}, + {"kind": "text:HEDGE", "x": 4, "y": 1}, + {"kind": "text:IS", "x": 5, "y": 1}, + {"kind": "text:STOP", "x": 6, "y": 1}, + {"kind": "wall", "x": 0, "y": 2}, + {"kind": "wall", "x": 1, "y": 2}, + {"kind": "wall", "x": 2, "y": 2}, + {"kind": "wall", "x": 3, "y": 2}, + {"kind": "wall", "x": 4, "y": 2}, + {"kind": "wall", "x": 5, "y": 2}, + {"kind": "wall", "x": 6, "y": 2}, + {"kind": "wall", "x": 3, "y": 3}, + {"kind": "wall", "x": 4, "y": 3}, + {"kind": "wall", "x": 5, "y": 3}, + {"kind": "wall", "x": 6, "y": 3}, + {"kind": "flag", "x": 5, "y": 4}, + {"kind": "wall", "x": 6, "y": 4}, + {"kind": "wall", "x": 4, "y": 5}, + {"kind": "wall", "x": 5, "y": 5}, + {"kind": "wall", "x": 6, "y": 5} + ], + "cards": [ + {"card": "Tin Knight", "x": 0, "y": 4}, + {"card": "Thorn Hedge", "x": 4, "y": 4}, + {"card": "Powder Keg", "x": 1, "y": 5}, + {"card": "Grave Warden", "x": 2, "y": 5} + ], + "hand": ["Whirlwind"] +} diff --git a/course/10-spellgrid/reference/rust/.course.json b/course/10-spellgrid/reference/rust/.course.json new file mode 100644 index 0000000..666d3bb --- /dev/null +++ b/course/10-spellgrid/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "10" } diff --git a/course/10-spellgrid/reference/rust/.gitignore b/course/10-spellgrid/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/10-spellgrid/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/10-spellgrid/reference/rust/Cargo.toml b/course/10-spellgrid/reference/rust/Cargo.toml new file mode 100644 index 0000000..0987cde --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/rust/README.md b/course/10-spellgrid/reference/rust/README.md new file mode 100644 index 0000000..ef5cd41 --- /dev/null +++ b/course/10-spellgrid/reference/rust/README.md @@ -0,0 +1,20 @@ +# Module 10 reference engine (Rust) + +The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds. + +- `cards/` and `levels/` (at the crate root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`. +- `src/engine/spellgrid.rs` (new): the only module that uses both engines. `SpellGrid` keeps a `World` and a `puzzle::Level` in step through the three seams: `rule_auras` (rules in force → auras), `CardSide::destroy` (hazard victims → one `Destroy` Sequence → the Death Creation Step), and `CardSide::sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id. +- Part I (`src/engine/`, outside `puzzle/`): + - `queue.rs`: `END_OF_PHASE` (`[DeathCreation, AuraUpdate]`) is the list of steps at the end of the outermost Phase, read from the world. `Action::SpawnText` records a `TextSpawned` event. + - `world.rs`: `set_rule_auras`/`rule_auras` (auras that no minion carries), `set_end_of_phase`/`end_of_phase`, `entity_count`. + - `aura.rs`: an Aura Update adds the rule auras for both players; `activations` applies an `Aura::TriggerCap`. + - `card.rs`: `Aura::TriggerCap` (not in the card format: only SpellGrid makes it), and `CardDef::noun`. + - `effect.rs`: `spawnText` is an action, not a panic. `event.rs`: `TextSpawned`. +- Part II (`src/engine/puzzle/`): + - `level.rs`: `Destroy` and `remove_all`. `step_with` and `move_you_with` take a destroy function, which movement, conversion (`X IS NOT X`) and hazards hand their victims to; `step` and `move_you` pass `remove_all`. `reparse` and `board_mut` are public. + - `grid.rs`: `Board::reserve_id`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid), with `LevelCard`. +- `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. +- `src/harness.rs`: Module 10 adds `new_spell_grid` and `SpellGrid`, including the test hook `with_naive_aura_timing()`, which reverses `END_OF_PHASE` for one game. +- `src/cli.rs` and `src/bin/spellgrid.rs` (new): the command line, `cargo run --bin spellgrid -- levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.rs` plays it from a script; it is the reference's own check, not a course test. + +Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/course/10-spellgrid/reference/rust/src/bin/spellgrid.rs b/course/10-spellgrid/reference/rust/src/bin/spellgrid.rs new file mode 100644 index 0000000..181b5aa --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/bin/spellgrid.rs @@ -0,0 +1,21 @@ +//! SpellGrid on the command line. See `src/cli.rs`. +//! +//! `cargo run --bin spellgrid -- levels/l1-double-tap.json` + +use std::io::{self, BufReader}; + +use engine::cli::{run, Outcome}; + +fn main() { + let Some(path) = std::env::args().nth(1) else { + eprintln!("usage: cargo run --bin spellgrid -- "); + std::process::exit(2); + }; + let level = std::fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!("cannot read {path}: {e}"); + std::process::exit(2); + }); + let input = Box::new(BufReader::new(io::stdin())); + let outcome = run(&level, input, Box::new(io::stdout())); + std::process::exit(if outcome == Outcome::Won { 0 } else { 1 }); +} diff --git a/course/10-spellgrid/reference/rust/src/cli.rs b/course/10-spellgrid/reference/rust/src/cli.rs new file mode 100644 index 0000000..ceb235c --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/cli.rs @@ -0,0 +1,199 @@ +//! SpellGrid on the command line: `cargo run --bin spellgrid -- levels/l1-double-tap.json`. +//! +//! A thin loop over the harness: it draws the level, reads a command, and calls +//! `step`, `cast` or `play`. No rules live here. +//! +//! ```text +//! w a s d one turn: up, left, down, right +//! c [x y] cast a spell from your hand (at the minion on (x, y), if given), +//! or play a minion from your hand onto the empty tile (x, y) +//! q quit +//! ``` +//! +//! Assemble's choices (Graft) are asked for one at a time: answer with a number or a name. + +use std::cell::RefCell; +use std::io::{BufRead, Write}; +use std::panic::{self, AssertUnwindSafe}; +use std::rc::Rc; + +use crate::harness::{new_spell_grid, Dir, LevelDef, SpellGrid}; + +/// How a game ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Won, + Lost, + Quit, +} + +/// The player's input and the screen, shared with the chooser. +struct Io { + input: Box, + output: Box, +} + +impl Io { + fn read_line(&mut self) -> Option { + let mut line = String::new(); + match self.input.read_line(&mut line) { + Ok(0) | Err(_) => None, + Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()), + } + } + + fn write(&mut self, s: &str) { + let _ = self.output.write_all(s.as_bytes()); + let _ = self.output.flush(); + } +} + +/// One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals. +fn cell(g: &SpellGrid, x: i32, y: i32) -> String { + let kinds = g.kinds_at(x, y); + // `kinds_at` is sorted; prefer text, which is what the player reads. + let Some(kind) = kinds + .iter() + .find(|k| k.starts_with("text:")) + .or(kinds.first()) + else { + return " . ".to_string(); + }; + let name = match kind.strip_prefix("text:") { + Some(word) => word.to_uppercase(), + None => kind.to_lowercase(), + }; + format!("{:<3}", name.chars().take(3).collect::()) +} + +/// The level as text: the grid, the rules, the minions and the hand. +pub fn render(g: &SpellGrid, level: &LevelDef) -> String { + let mut lines = Vec::new(); + for y in 0..level.height { + let row: Vec = (0..level.width).map(|x| cell(g, x, y)).collect(); + lines.push(row.join(" ")); + } + lines.push(format!("Rules: {}", g.active_rules().join(", "))); + let mut minions = Vec::new(); + for y in 0..level.height { + for x in 0..level.width { + if let Some(id) = g.minion_at(x, y) { + let (name, health) = (g.name_of(id), g.health(id)); + minions.push(format!("{name} ({x},{y}) {health} Health")); + } + } + } + let or = |list: Vec, none: &str| { + if list.is_empty() { + none.to_string() + } else { + list.join(", ") + } + }; + lines.push(format!( + "Minions: {}", + or(minions, "none").replace(", ", "; ") + )); + lines.push(format!("Hand: {}", or(g.hand(), "empty"))); + lines.join("\n") +} + +/// A `c` command: `c [x y]`. +fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) { + let n = args.len(); + if n >= 3 { + if let (Ok(x), Ok(y)) = (args[n - 2].parse(), args[n - 1].parse()) { + return (args[..n - 2].join(" "), Some((x, y))); + } + } + (args.join(" "), None) +} + +/// Play `level_text` (a level in the course's format), reading commands from `input` +/// and writing to `output`. +pub fn run(level_text: &str, input: Box, output: Box) -> Outcome { + let level: LevelDef = + serde_json::from_str(level_text).expect("a level in the course's level format"); + let mut g = new_spell_grid(level.clone(), vec![]); + let io = Rc::new(RefCell::new(Io { input, output })); + let chooser_io = Rc::clone(&io); + g.set_chooser(move |kind, options| { + let mut io = chooser_io.borrow_mut(); + let listed: Vec = options + .iter() + .enumerate() + .map(|(i, o)| format!(" {}) {o}", i + 1)) + .collect(); + io.write(&format!( + "Choose the {kind} (a number or a name):\n{}\n? ", + listed.join("\n") + )); + let answer = io.read_line().unwrap_or_default().trim().to_string(); + if options.contains(&answer) { + return answer; + } + let picked = answer + .parse::() + .ok() + .and_then(|n| options.get(n.wrapping_sub(1))); + picked.unwrap_or(&options[0]).clone() + }); + // The harness panics on a move the rules don't allow (a card not in the hand, a + // full tile). The CLI reports the message instead of stopping. + let hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let outcome = play(&mut g, &level, &io); + panic::set_hook(hook); + outcome +} + +/// Draw, read a command, do it; until the level is won or lost, or the player quits. +fn play(g: &mut SpellGrid, level: &LevelDef, io: &Rc>) -> Outcome { + loop { + io.borrow_mut().write(&format!("{}\n", render(g, level))); + if g.won() { + io.borrow_mut().write("You win!\n"); + return Outcome::Won; + } + if g.lost() { + io.borrow_mut().write("Nothing is YOU. You lose.\n"); + return Outcome::Lost; + } + io.borrow_mut().write("> "); + let Some(line) = io.borrow_mut().read_line() else { + return Outcome::Quit; + }; + let words: Vec<&str> = line.split_whitespace().collect(); + let dir = match words.first() { + Some(&"q") => return Outcome::Quit, + Some(&"w") => Some(Dir::Up), + Some(&"a") => Some(Dir::Left), + Some(&"s") => Some(Dir::Down), + Some(&"d") => Some(Dir::Right), + _ => None, + }; + let result = panic::catch_unwind(AssertUnwindSafe(|| match (dir, words.as_slice()) { + (Some(dir), _) => g.step(dir), + (None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) { + (card, None) => g.cast(&card, None), + (card, Some((x, y))) => match g.minion_at(x, y) { + Some(target) => g.cast(&card, Some(target)), + None => { + g.play(&card, x, y); + } + }, + }, + _ => io + .borrow_mut() + .write("Commands: w a s d (move), c [x y] (cast or play), q (quit)\n"), + })); + if let Err(payload) = result { + let message = payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "that can't be done".to_string()); + io.borrow_mut().write(&format!("{message}\n")); + } + } +} diff --git a/course/10-spellgrid/reference/rust/src/content/hazards.rs b/course/10-spellgrid/reference/rust/src/content/hazards.rs new file mode 100644 index 0000000..c86a289 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/content/hazards.rs @@ -0,0 +1,54 @@ +//! Part II: what destroys what at the end of a turn. The engine runs these +//! hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT, +//! then DEFEAT), and destroys each one's victims together before the next runs. +//! A hazard only reaches objects on the same float level: it asks the level what +//! shares a tile (`Level::sharing`). A new hazard property is one more function here. + +use crate::engine::puzzle::{Hazard, Level, ObjectId}; + +/// The hazards, in the order the engine runs them. +pub fn hazards() -> Vec { + vec![sink, hot_melt, defeat] +} + +/// The objects that have `property`, oldest first. +fn with(level: &Level, property: &str) -> Vec { + level + .board() + .objects() + .map(|o| o.id) + .filter(|&id| level.has_property(id, property)) + .collect() +} + +/// A SINK object that shares its tile with anything destroys all of it, and itself. +fn sink(level: &Level) -> Vec { + let mut doomed = vec![]; + for id in with(level, "SINK") { + let others = level.sharing(id); + if !others.is_empty() { + doomed.push(id); + doomed.extend(others); + } + } + doomed +} + +/// A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one. +fn hot_melt(level: &Level) -> Vec { + destroyed_by(level, "MELT", "HOT") +} + +/// A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one. +fn defeat(level: &Level) -> Vec { + destroyed_by(level, "YOU", "DEFEAT") +} + +/// The objects with `victim` that have `killer` too, or share a tile with one that does. +fn destroyed_by(level: &Level, victim: &str, killer: &str) -> Vec { + let deadly = |id: ObjectId| level.has_property(id, killer); + with(level, victim) + .into_iter() + .filter(|&id| deadly(id) || level.sharing(id).into_iter().any(deadly)) + .collect() +} diff --git a/course/10-spellgrid/reference/rust/src/content/mod.rs b/course/10-spellgrid/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..fd5781f --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/content/mod.rs @@ -0,0 +1,60 @@ +//! Content: the cards and the Baba Is You words. Only this directory (and the card data in `cards/`) may name +//! specific cards, or specific Baba Is You nouns. +//! +//! From Module 5 every card is a JSON file in `cards/` at the crate root, in the +//! course's card format (`course/schema/card.schema.json`). The functions that +//! were card effects in Modules 2–4 are gone: the engine interprets the data. +//! `start-module` copies the course's card files into `cards/`; add your own there. + +use std::path::Path; +use std::sync::OnceLock; + +use crate::engine::CardDef; + +mod hazards; +mod movement; +pub use hazards::hazards; +pub use movement::responses as movement; + +/// Every card in `cards/`, in file-name order. Read once per test run. +pub fn cards() -> Vec { + 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 III: the words that cross between the engines (`seams.json`). A noun that +/// names a trigger, and a property that multiplies one: `DEATHRATTLE IS DOUBLE`. +pub fn seams() -> crate::engine::SeamWords { + serde_json::from_str(include_str!("seams.json")) + .expect("src/content/seams.json is not a seam word table") +} + +/// Part II: the words of Baba Is You the parser can read, besides the engine's own +/// (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one +/// more entry in `words.json` in this directory. +pub fn words() -> Vec { + serde_json::from_str(include_str!("words.json")) + .expect("src/content/words.json is not a word list") +} diff --git a/course/10-spellgrid/reference/rust/src/content/movement.rs b/course/10-spellgrid/reference/rust/src/content/movement.rs new file mode 100644 index 0000000..c918757 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/content/movement.rs @@ -0,0 +1,44 @@ +//! Part II: what each property does when an object moves. The engine plans a move +//! one step at a time and runs these responses for every step, in this order (the +//! order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new +//! movement property is one more response here. + +use crate::engine::puzzle::{Planner, Response, Step}; + +/// The movement responses, in the order the engine runs them. +pub fn responses() -> Vec { + vec![open_shut, push, stop] +} + +/// An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one) +/// destroys both, and that object doesn't block the step. One partner per step: +/// the oldest. +fn open_shut(p: &mut Planner, s: Step) -> bool { + for (mine, theirs) in [("OPEN", "SHUT"), ("SHUT", "OPEN")] { + if !p.has(s.mover, mine) { + continue; + } + let partner = p.staying_at(s.to).into_iter().find(|&o| p.has(o, theirs)); + if let Some(partner) = partner { + p.destroy(partner); + p.destroy(s.mover); + return true; + } + } + true +} + +/// Every PUSH object on the tile ahead must step the same way, or the step fails. +fn push(p: &mut Planner, s: Step) -> bool { + for o in p.staying_at(s.to) { + if p.has(o, "PUSH") && !p.try_move(o) { + return false; + } + } + true +} + +/// A STOP object still on the tile ahead blocks the step. +fn stop(p: &mut Planner, s: Step) -> bool { + !p.staying_at(s.to).into_iter().any(|o| p.has(o, "STOP")) +} diff --git a/course/10-spellgrid/reference/rust/src/content/seams.json b/course/10-spellgrid/reference/rust/src/content/seams.json new file mode 100644 index 0000000..656e98b --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/content/seams.json @@ -0,0 +1,4 @@ +{ + "triggerWords": { "DEATHRATTLE": "deathrattle" }, + "multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } } +} diff --git a/course/10-spellgrid/reference/rust/src/content/words.json b/course/10-spellgrid/reference/rust/src/content/words.json new file mode 100644 index 0000000..5c49029 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/content/words.json @@ -0,0 +1,31 @@ +[ + { "name": "BABA", "role": "noun" }, + { "name": "ROCK", "role": "noun" }, + { "name": "WALL", "role": "noun" }, + { "name": "FLAG", "role": "noun" }, + { "name": "LAVA", "role": "noun" }, + { "name": "KEY", "role": "noun" }, + { "name": "DOOR", "role": "noun" }, + { "name": "SKULL", "role": "noun" }, + { "name": "WATER", "role": "noun" }, + { "name": "KEG", "role": "noun" }, + { "name": "BRAMBLE", "role": "noun" }, + { "name": "HEDGE", "role": "noun" }, + { "name": "WARDEN", "role": "noun" }, + { "name": "MOTH", "role": "noun" }, + { "name": "SCRIBE", "role": "noun" }, + { "name": "KNIGHT", "role": "noun" }, + { "name": "DEATHRATTLE", "role": "noun" }, + { "name": "YOU", "role": "property" }, + { "name": "WIN", "role": "property" }, + { "name": "PUSH", "role": "property" }, + { "name": "STOP", "role": "property" }, + { "name": "OPEN", "role": "property" }, + { "name": "SHUT", "role": "property" }, + { "name": "HOT", "role": "property" }, + { "name": "MELT", "role": "property" }, + { "name": "SINK", "role": "property" }, + { "name": "DEFEAT", "role": "property" }, + { "name": "FLOAT", "role": "property" }, + { "name": "DOUBLE", "role": "property" } +] diff --git a/course/10-spellgrid/reference/rust/src/engine/assemble.rs b/course/10-spellgrid/reference/rust/src/engine/assemble.rs new file mode 100644 index 0000000..85f4da9 --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/rust/src/engine/aura.rs b/course/10-spellgrid/reference/rust/src/engine/aura.rs new file mode 100644 index 0000000..37eb4c8 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/aura.rs @@ -0,0 +1,67 @@ +//! Auras, and the one routine that applies trigger multipliers. +//! +//! An aura is data on a card: a continuous effect its minion has while in play. +//! The engine never asks "is that card on the board?" in the middle of resolving +//! something. It takes a snapshot of the auras in force at defined points (an Aura +//! Update) and every rule reads the snapshot. +//! +//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for +//! Health/Attack auras before the Death Creation Step, and one for every other aura +//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death +//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase. +//! Summoning a minion also runs an Aura Update. +//! +//! Module 10: auras can come from outside the cards. SpellGrid compiles rules on its +//! grid into auras (`World::set_rule_auras`); they join the snapshot at the next Aura +//! Update, beside the minions' own, and go through the same routine. A rule's +//! multiplier stacks with a card's by the same `stacking` field. A cap (`IS NOT`) +//! limits the total, whatever multiplies it. + +use super::card::{Aura, Stacking, TriggerOn}; +use super::world::{ActiveAura, PlayerId, World}; + +/// Aura Update (Other): replace the snapshot with the auras of every minion now in +/// play, and the auras from outside the cards, which apply to both players. +pub fn aura_update_other(world: &mut World) { + let mut active = Vec::new(); + for id in world.minions_in_play() { + for &aura in &world.card(world.card_name(id)).auras { + active.push(ActiveAura { + owner: world.owner_of(id), + aura, + }); + } + } + for &aura in world.rule_auras() { + for owner in 0..2 { + active.push(ActiveAura { owner, aura }); + } + } + world.set_active_auras(active); +} + +/// How many times a trigger listening for `on`, on a minion `owner` controls, activates +/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in +/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest +/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations. A cap +/// (`Aura::TriggerCap`) then limits the total to its `times`. +pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 { + let mut max = 1; + let mut extra = 0; + let mut cap = u32::MAX; + for active in world.active_auras().iter().filter(|a| a.owner == owner) { + match active.aura { + Aura::TriggerCap { trigger, times } if trigger == on => cap = cap.min(times), + Aura::TriggerMultiplier { + trigger, + times, + stacking, + } if trigger == on => match stacking { + Stacking::Max => max = max.max(times), + Stacking::Additive => extra += times - 1, + }, + _ => {} + } + } + cap.min(max + extra) +} diff --git a/course/10-spellgrid/reference/rust/src/engine/card.rs b/course/10-spellgrid/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..6aeec1b --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/card.rs @@ -0,0 +1,297 @@ +//! Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`), +//! read straight from JSON. The engine compiles each definition once, when it is +//! registered, into the lists it reads while resolving: triggers, auras and a +//! spell's effects. The definitions themselves live in `cards/`, loaded by `content`. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// What a triggered ability listens for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TriggerOn { + /// This minion takes damage. + SelfDamaged, + /// A minion with the same owner (this one included) takes damage. + FriendlyMinionDamaged, + /// Any minion takes damage. + AnyMinionDamaged, + /// Either hero takes damage. + HeroDamaged, + /// This minion dies. It resolves after the minion has left play. + Deathrattle, + /// This minion is played from hand. + Battlecry, + /// The end of its owner's turn. The course has no turns, so it never fires; card + /// data may still name it, for example in a trigger multiplier. + EndOfTurn, +} + +impl TriggerOn { + /// The trigger's family name, as reported in `Triggered` events. + pub fn label(self) -> &'static str { + match self { + TriggerOn::SelfDamaged + | TriggerOn::FriendlyMinionDamaged + | TriggerOn::AnyMinionDamaged + | TriggerOn::HeroDamaged => "damaged", + TriggerOn::Deathrattle => "deathrattle", + TriggerOn::Battlecry => "battlecry", + TriggerOn::EndOfTurn => "endOfTurn", + } + } +} + +/// How several trigger multipliers for the same trigger combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stacking { + /// The largest multiplier wins: two copies of a doubler still double. + Max, + /// Each copy adds its extra activations. + Additive, +} + +/// An aura: a continuous effect a minion has while it is in play. The engine reads +/// auras only at the Aura Update points (see `aura.rs`), never mid-Phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Aura { + /// Triggers listening for `trigger`, belonging to minions with the aura owner's + /// owner, activate `times` times each. + TriggerMultiplier { + trigger: TriggerOn, + times: u32, + stacking: Stacking, + }, + /// Module 10: triggers listening for `trigger` activate at most `times` times, + /// whatever multiplies them. Not in the card format: SpellGrid compiles it from a + /// negated rule (`DEATHRATTLE IS NOT DOUBLE`). + TriggerCap { trigger: TriggerOn, times: u32 }, +} + +/// Who an effect touches. `This` is the schema's `"self"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Target { + #[serde(rename = "self")] + This, + AllMinions, + AllOtherMinions, + AllOtherCharacters, + EnemyHero, + Chosen, +} + +/// Which minions an Assemble may pick as its base. An empty filter allows any. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct PoolFilter { + /// Only minions with a trigger clause listening for this. + #[serde(default)] + pub trigger: Option, +} + +/// One effect: a node of a card's ability, interpreted by `effect.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Effect { + DealDamage { + target: Target, + amount: i32, + }, + Draw { + count: u32, + }, + Summon { + card: String, + count: u32, + }, + GainArmor { + amount: i32, + }, + GainAttack { + target: Target, + amount: i32, + }, + Destroy { + target: Target, + }, + /// Part III (SpellGrid) only: puts a word on the grid. + SpawnText { + word: String, + at: String, + }, + /// Get a minion from the pool, with half its text replaced (see `assemble.rs`). + Assemble { + filter: PoolFilter, + }, + /// Give every minion in the owner's hand +attack/+health. + BuffHand { + attack: i32, + health: i32, + }, +} + +/// One line of card text. A trigger clause applies to the effect clauses after it, +/// until the next trigger, keyword or aura. Effects before any trigger are what a +/// spell does when cast. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Clause { + Keyword { keyword: String }, + Trigger { on: TriggerOn }, + Aura { modifier: Aura }, + Effect { effect: Effect }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CardType { + Minion, + Spell, +} + +/// A card as data, in the course's card format. Fields the engine doesn't use +/// (`fictional`, `game`) are ignored. `noun` is read by SpellGrid (Module 10). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct CardDef { + pub name: String, + #[serde(rename = "type")] + pub card_type: CardType, + pub cost: i32, + #[serde(default)] + pub attack: i32, + #[serde(default)] + pub health: i32, + pub text: Vec, + /// `text[..split]` is the top half, `text[split..]` the bottom half. + pub split: usize, + /// The noun a SpellGrid minion has on the grid (Module 10). + #[serde(default)] + pub noun: Option, +} + +/// 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/10-spellgrid/reference/rust/src/engine/death.rs b/course/10-spellgrid/reference/rust/src/engine/death.rs new file mode 100644 index 0000000..69a00e3 --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/rust/src/engine/effect.rs b/course/10-spellgrid/reference/rust/src/engine/effect.rs new file mode 100644 index 0000000..300436b --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/effect.rs @@ -0,0 +1,94 @@ +//! The interpreter: turns one effect, as data, into engine actions. +//! +//! This is the only place that knows what an effect *means*. A card's text is a +//! small tree (clauses holding effects holding targets); the engine walks it. The +//! effect vocabulary is closed: a card can only do what this match can do, which is +//! what makes a data card safe to load. + +use super::card::{Effect, Target}; +use super::queue::{Action, Ctx}; +use super::world::EntityId; + +/// The actions one effect performs, in order, given where and for whom it runs. +pub fn actions(effect: &Effect, ctx: &Ctx) -> Vec { + let owner = ctx.owner; + match effect { + Effect::DealDamage { target, amount } => vec![Action::Damage { + targets: targets(*target, ctx), + amount: *amount, + }], + Effect::Draw { count } => (0..*count) + .map(|_| Action::Draw { player: owner }) + .collect(), + Effect::Summon { card, count } => (0..*count) + .map(|_| Action::Summon { + card: card.clone(), + player: owner, + at: summon_position(ctx), + }) + .collect(), + Effect::GainArmor { amount } => vec![Action::GainArmor { + player: owner, + amount: *amount, + }], + Effect::GainAttack { target, amount } => targets(*target, ctx) + .into_iter() + .map(|id| Action::GainAttack { + id, + amount: *amount, + }) + .collect(), + Effect::Destroy { target } => vec![Action::Destroy { + targets: targets(*target, ctx), + }], + Effect::Assemble { filter } => vec![Action::Assemble { + player: owner, + filter: filter.clone(), + }], + Effect::BuffHand { attack, health } => ctx + .world + .minions_in_hand(owner) + .into_iter() + .map(|id| Action::Buff { + id, + attack: *attack, + health: *health, + }) + .collect(), + Effect::SpawnText { word, .. } => vec![Action::SpawnText { + source: ctx.minion(), + word: word.clone(), + }], + } +} + +/// The entities a target names, from where the effect runs. +fn targets(target: Target, ctx: &Ctx) -> Vec { + 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/10-spellgrid/reference/rust/src/engine/event.rs b/course/10-spellgrid/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..0a0ff2b --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/event.rs @@ -0,0 +1,58 @@ +//! What happened, in order. The engine records an event each time it changes the game. + +use super::card::TriggerOn; +use super::world::{EntityId, PlayerId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Summoned { + id: EntityId, + }, + Damaged { + target: EntityId, + amount: i32, + }, + Triggered { + source: EntityId, + on: TriggerOn, + }, + Drew { + player: PlayerId, + id: EntityId, + }, + Fatigue { + player: PlayerId, + }, + ArmorGained { + player: PlayerId, + amount: i32, + }, + StatsChanged { + id: EntityId, + attack: i32, + health: i32, + }, + SpellCast { + card: String, + }, + /// A Death Event: the Death Creation Step moved `id` from play to the graveyard. + /// `position` is the board index it had when the step began; its Deathrattle summons there. + Destroyed { + id: EntityId, + position: usize, + }, + DeathPhase { + round: u32, + }, + /// A minion was played from hand (it has just entered the board). Its Battlecry answers this. + Played { + id: EntityId, + }, + /// Module 10: `source`'s effect asked for `word` to be written on its grid tile. + /// Part I has no grid: it only records the request. SpellGrid reads it when the + /// Sequence ends. + TextSpawned { + source: EntityId, + word: String, + }, +} diff --git a/course/10-spellgrid/reference/rust/src/engine/mod.rs b/course/10-spellgrid/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..4099972 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/mod.rs @@ -0,0 +1,55 @@ +//! Engine core: generic rules machinery. Never name a specific card here. +//! +//! Module 1: every card in the game is an entity addressed by an id handle. +//! The engine knows which zone each entity is in, who owns it, and when it +//! entered play. +//! +//! Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +//! action queue that resolves a Sequence depth-first, recording an event log. +//! +//! Module 3: deferred death. Damage and destroy effects only mark minions; the +//! Death Creation Step removes them when the outermost Phase ends, and Death +//! Phases resolve their Deathrattles until no new deaths occur. +//! +//! Module 4: auras as card data, Aura Updates at defined points, and one +//! routine that decides how many times a queued trigger activates. Playing a +//! minion from hand, and Battlecries. +//! +//! Module 5: behaviour as data. Cards are definitions in the course's card format, +//! loaded from JSON; one interpreter turns their effects into actions. Assemble +//! builds a new card by swapping half of one card's text for half of another's. +//! +//! Part II lives in `puzzle`: a second engine, for the rule-writing puzzle game, +//! beside the first. +//! Module 6: the grid, the lexer and the parser. +//! Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force. +//! Module 8: movement, planned in full and then committed; push chains. +//! Module 9: the turn, as a list of phases; hazards; winning. +//! +//! Part III lives in `spellgrid`, the only module that knows both engines. +//! Module 10: SpellGrid. Part I gains auras from outside the cards +//! (`World::set_rule_auras`), a cap on activations, the `spawnText` effect (recorded as a +//! `TextSpawned` event) and the end-of-Phase steps as a list (`END_OF_PHASE`); Part II +//! gains a destroy function. + +mod assemble; +mod aura; +mod card; +mod death; +mod effect; +mod event; +pub mod puzzle; +mod queue; +mod spellgrid; +mod world; + +pub use assemble::{assemble, register_assembled, AssembleError, Half}; +pub use aura::aura_update_other; +pub use card::{ + Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target, + Trigger, TriggerOn, +}; +pub use event::Event; +pub use queue::{run_sequence, summon, Action, Ctx, EndOfPhaseStep, END_OF_PHASE}; +pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid}; +pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/grid.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/grid.rs new file mode 100644 index 0000000..79d531a --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/grid.rs @@ -0,0 +1,175 @@ +//! The grid: objects on tiles. `x` grows right, `y` grows down, and any number of +//! objects may share a tile. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +pub type ObjectId = u32; + +/// A level in the course's level format (`course/schema/level.schema.json`). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct LevelDef { + pub width: i32, + pub height: i32, + #[serde(default)] + pub objects: Vec, + /// Module 10, read only by SpellGrid: minions in play, each on its tile. + #[serde(default)] + pub cards: Vec, + /// Module 10, read only by SpellGrid: the cards in player 0's hand. + #[serde(default)] + pub hand: Vec, +} + +/// A minion card on the grid (Module 10). +#[derive(Debug, Clone, Deserialize)] +pub struct LevelCard { + pub card: String, + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LevelObject { + pub kind: String, + pub x: i32, + pub y: i32, +} + +/// What an object is: a thing named by a noun (kind `""`, 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(), + }, + } + } +} + +/// The kind as it is placed: `""` in lower case, or `"text:"`. +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Kind::Thing { noun } => write!(f, "{}", noun.to_lowercase()), + Kind::Text { word } => write!(f, "{}{word}", Self::TEXT_PREFIX), + } + } +} + +#[derive(Debug, Clone)] +pub struct Object { + pub id: ObjectId, + pub kind: Kind, + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone, Default)] +pub struct Board { + pub width: i32, + pub height: i32, + objects: BTreeMap, + next_id: ObjectId, +} + +impl Board { + pub fn new(width: i32, height: i32) -> Board { + Board { + width, + height, + ..Default::default() + } + } + + pub fn in_bounds(&self, x: i32, y: i32) -> bool { + (0..self.width).contains(&x) && (0..self.height).contains(&y) + } + + /// Add an object; ids start at 1 and are never reused. Panics off the board. + pub fn place(&mut self, kind: Kind, x: i32, y: i32) -> ObjectId { + assert!( + self.in_bounds(x, y), + "({x}, {y}) is outside the {}x{} level", + self.width, + self.height + ); + self.next_id += 1; + let id = self.next_id; + self.objects.insert(id, Object { id, kind, x, y }); + id + } + + /// An id that no object will ever have (Module 10): SpellGrid gives one to each + /// card that isn't on the grid, so cards and objects share one id space. + pub fn reserve_id(&mut self) -> ObjectId { + self.next_id += 1; + self.next_id + } + + /// The object `id`. Panics if there is none. + pub fn get(&self, id: ObjectId) -> &Object { + self.objects + .get(&id) + .unwrap_or_else(|| panic!("no object {id}")) + } + + /// The object `id`, or `None` once it has been removed. Panics on an id this + /// board never issued. + pub fn find(&self, id: ObjectId) -> Option<&Object> { + assert!((1..=self.next_id).contains(&id), "no object {id}"); + self.objects.get(&id) + } + + /// Take object `id` off the board for good. + pub fn remove(&mut self, id: ObjectId) { + self.objects.remove(&id); + } + + /// Move object `id` by `(dx, dy)`. Panics if it would leave the board. + pub fn shift(&mut self, id: ObjectId, (dx, dy): (i32, i32)) { + let (width, height) = (self.width, self.height); + let o = self + .objects + .get_mut(&id) + .unwrap_or_else(|| panic!("no object {id}")); + let (x, y) = (o.x + dx, o.y + dy); + assert!( + (0..width).contains(&x) && (0..height).contains(&y), + "({x}, {y}) is outside the {width}x{height} level" + ); + (o.x, o.y) = (x, y); + } + + /// Make object `id` a `kind`, where it stands. Panics if there is no such object. + pub fn set_kind(&mut self, id: ObjectId, kind: Kind) { + self.objects + .get_mut(&id) + .unwrap_or_else(|| panic!("no object {id}")) + .kind = kind; + } + + /// The objects on tile (x, y), oldest first. + pub fn at(&self, x: i32, y: i32) -> impl Iterator { + self.objects().filter(move |o| (o.x, o.y) == (x, y)) + } + + /// Every object, oldest first. + pub fn objects(&self) -> impl Iterator { + self.objects.values() + } +} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs new file mode 100644 index 0000000..a046928 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs @@ -0,0 +1,293 @@ +//! A level in play: the board, the words content provides, and the rules compiled +//! from them. Every change to the board recompiles the rules from scratch: parse, +//! then resolve. Every question about what an object is goes through `has_property`. +//! +//! A move (Module 8) is planned for each `YOU` object, then committed; the rules are +//! reparsed once, after all of them. +//! +//! A turn (Module 9) runs the phases of `TURN` in order: movement, parse, +//! conversion, parse, content's hazards, the win check, parse. +//! +//! Module 10: what happens to the objects a turn destroys is a destroy function the +//! caller may pass in (`step_with`, `move_you_with`). The default, `remove_all`, +//! removes them. SpellGrid passes one that sends minions to Part I's Death Creation +//! Step instead. + +use super::grid::{Board, Kind, LevelDef, ObjectId}; +use super::movement::{Dir, Plan, Planner, Response}; +use super::parse::{parse, Rule}; +use super::resolve::resolve; +use super::turn::{Hazard, Phase, TURN}; +use super::word::{Lexicon, WordDef}; + +#[derive(Debug, Clone)] +pub struct Level { + board: Board, + lexicon: Lexicon, + responses: Vec, + hazards: Vec, + rules: Vec, + active: Vec, + won: bool, +} + +/// The property that makes an object move when the player moves. +pub const YOU: &str = "YOU"; +/// The property that wins the level when a `YOU` object is on it, or is it. +pub const WIN: &str = "WIN"; +/// The property that puts an object on the other float level: only objects on +/// the same level share a tile for hazards and for winning. +pub const FLOAT: &str = "FLOAT"; + +/// What to do with objects a move or a turn destroys (Module 10). It may change the +/// board, and reparse, before it returns. An id may appear more than once. +pub type Destroy<'a> = dyn FnMut(&mut Level, &[ObjectId]) + 'a; + +/// The default destroy function: take every object off the board. +pub fn remove_all(level: &mut Level, ids: &[ObjectId]) { + for &id in ids { + level.board.remove(id); + } +} + +impl Level { + /// A level from its definition, the words content provides, and content's + /// movement responses and hazards, each in the order they are to run. + pub fn new( + def: &LevelDef, + words: &[WordDef], + responses: Vec, + hazards: Vec, + ) -> Level { + let mut level = Level { + board: Board::new(def.width, def.height), + lexicon: Lexicon::new(words), + responses, + hazards, + rules: vec![], + active: vec![], + won: false, + }; + for o in &def.objects { + level.board.place(Kind::parse(&o.kind), o.x, o.y); + } + level.reparse(); + level + } + + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { + let id = self.board.place(Kind::parse(kind), x, y); + self.reparse(); + id + } + + pub fn board(&self) -> &Board { + &self.board + } + + /// The board, to change directly (Module 10: SpellGrid writes text and removes + /// minions). Nothing is reparsed until `reparse` is called. + pub fn board_mut(&mut self) -> &mut Board { + &mut self.board + } + + /// The movement responses, in the order they run. + pub fn responses(&self) -> &[Response] { + &self.responses + } + + /// Every rule the text spells, as parsed: negated rules and duplicates included. + pub fn rules(&self) -> &[Rule] { + &self.rules + } + + /// The rules in force after the resolution pass. + pub fn active_rules(&self) -> &[Rule] { + &self.active + } + + /// Does object `id` have `property`? Yes if an active rule gives it to a noun + /// that names the object and no active negated rule takes it from one. A + /// destroyed object has no properties. + pub fn has_property(&self, id: ObjectId, property: &str) -> bool { + let Some(object) = self.board.find(id) else { + return false; + }; + let kind = &object.kind; + let about_it: Vec<&Rule> = self + .active + .iter() + .filter(|r| r.complement == property && self.lexicon.names(&r.subject, kind)) + .collect(); + about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated) + } + + /// The other objects on object `id`'s tile that are on its float level (both + /// `FLOAT` or both not), oldest first. Hazards and the win check ask this. + pub fn sharing(&self, id: ObjectId) -> Vec { + let o = self.board.get(id); + let floats = self.has_property(id, FLOAT); + self.board + .at(o.x, o.y) + .filter(|other| other.id != id && self.has_property(other.id, FLOAT) == floats) + .map(|other| other.id) + .collect() + } + + /// Has a turn's win check found a `YOU` object on a `WIN` one? Stays true. + pub fn won(&self) -> bool { + self.won + } + + /// Is there no `YOU` object left? + pub fn lost(&self) -> bool { + !self.board.objects().any(|o| self.has_property(o.id, YOU)) + } + + /// Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement + /// only: no conversion, hazard or win. + pub fn move_you(&mut self, dir: Dir) { + self.move_you_with(dir, &mut remove_all); + } + + /// `move_you`, with destroyed objects going to `destroy` (Module 10). + pub fn move_you_with(&mut self, dir: Dir, destroy: &mut Destroy) { + self.movement(dir, destroy); + self.reparse(); + } + + /// Play one turn: the phases of `TURN`, in order. + pub fn step(&mut self, dir: Dir) { + self.step_with(dir, &mut remove_all); + } + + /// `step`, with destroyed objects going to `destroy` (Module 10). + pub fn step_with(&mut self, dir: Dir, destroy: &mut Destroy) { + for phase in TURN { + match phase { + Phase::Movement => self.movement(dir, destroy), + Phase::Parse => self.reparse(), + Phase::Conversion => self.convert(destroy), + Phase::Hazards => self.run_hazards(destroy), + Phase::Win => self.check_win(), + } + } + } + + /// The `YOU` objects move one at a time, the one furthest ahead in `dir` first + /// (oldest first on a tie); each one's move is planned in full, then committed, + /// before the next is planned. Every move uses the rules in force before the + /// first. + fn movement(&mut self, dir: Dir, destroy: &mut Destroy) { + let (dx, dy) = dir.delta(); + let mut you: Vec<_> = self + .board + .objects() + .filter(|o| self.has_property(o.id, YOU)) + .map(|o| (-(o.x * dx + o.y * dy), o.id)) + .collect(); + you.sort(); + for (_, id) in you { + if self.board.find(id).is_none() { + continue; // destroyed by an earlier move + } + let mut planner = Planner::new(self, dir); + if planner.try_move(id) { + let plan = planner.into_plan(); + self.commit(&plan, dir, destroy); + } + } + } + + /// Destroy, then move what is still on the board (a destroy function may remove + /// more than it was given). + fn commit(&mut self, plan: &Plan, dir: Dir, destroy: &mut Destroy) { + if !plan.destroyed.is_empty() { + let ids: Vec = plan.destroyed.iter().copied().collect(); + destroy(self, &ids); + } + for &id in plan.moving.difference(&plan.destroyed) { + if self.board.find(id).is_some() { + self.board.shift(id, dir.delta()); + } + } + } + + /// Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y` + /// where it stands, keeping its id; with several `Y`s it becomes the first, + /// and each other `Y` is a new object on its tile. An `X IS X` naming the + /// object blocks this. An object named by `X IS NOT X` is deleted, and still + /// makes its `Y`s, as new objects: the negation cancelled `X IS X`. + /// + /// Every object is decided from the board as movement left it, before any + /// changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. + fn convert(&mut self, destroy: &mut Destroy) { + let mut changes = vec![]; + for o in self.board.objects() { + let about_it: Vec<&Rule> = self + .active + .iter() + .filter(|r| self.lexicon.names(&r.subject, &o.kind)) + .collect(); + let deleted = about_it.iter().any(|r| r.negated && r.is_identity()); + let locked = about_it.iter().any(|r| !r.negated && r.is_identity()); + let mut into: Vec = vec![]; + if !locked { + for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) { + match self.lexicon.thing_named(&r.complement) { + Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind), + _ => {} + } + } + } + if deleted || !into.is_empty() { + changes.push((o.id, (o.x, o.y), deleted, into)); + } + } + let mut deleted_ids = vec![]; + for (id, (x, y), deleted, into) in changes { + let mut into = into.into_iter(); + if deleted { + deleted_ids.push(id); + } else if let Some(first) = into.next() { + self.board.set_kind(id, first); + } + for kind in into { + self.board.place(kind, x, y); + } + } + if !deleted_ids.is_empty() { + destroy(self, &deleted_ids); + } + } + + /// Run content's hazards in order. Each one's victims are destroyed together + /// before the next runs; no reparse until the turn's last phase. + fn run_hazards(&mut self, destroy: &mut Destroy) { + for i in 0..self.hazards.len() { + let hazard = self.hazards[i]; + let victims = hazard(self); + if !victims.is_empty() { + destroy(self, &victims); + } + } + } + + /// The level is won if any `YOU` object is `WIN`, or shares its tile with a + /// `WIN` object on its float level. + fn check_win(&mut self) { + let wins = |id: ObjectId| self.has_property(id, WIN); + let won = self + .board + .objects() + .filter(|o| self.has_property(o.id, YOU)) + .any(|o| wins(o.id) || self.sharing(o.id).into_iter().any(wins)); + self.won |= won; + } + + /// Recompile the rules from the board: parse, then resolve. + pub fn reparse(&mut self) { + self.rules = parse(&self.board, &self.lexicon); + self.active = resolve(&self.rules); + } +} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/mod.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/mod.rs new file mode 100644 index 0000000..2bda31d --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/mod.rs @@ -0,0 +1,40 @@ +//! Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is +//! compiled from the text on the board, and recompiled whenever the board changes. +//! +//! Module 6: the grid, the lexer (text objects to words with grammatical roles) +//! and the parser (words to rules). The engine knows the operators `IS`, `AND`, +//! `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word, +//! nouns included, is data handed in by content. +//! +//! Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the +//! identity lock) that turns the parsed rules into the rules in force. Objects are +//! asked about through `Level::has_property`, which knows which nouns name them. +//! +//! Module 8: movement. `Level::move_you` plans each `YOU` object's move in full +//! (`Planner`), then commits it, then reparses. What a property does when something +//! moves is a `Response` that content provides; the engine runs them in order. +//! +//! Module 9: the turn. `Level::step` runs the phases of `TURN` in order: movement, +//! parse, conversion, parse, hazards, win, parse. What a hazard property destroys +//! is a `Hazard` that content provides; the engine knows `YOU`, `WIN` and `FLOAT`. +//! +//! Module 10: `Level::step_with` and `Level::move_you_with` take a destroy function, +//! which decides what happens to the objects they destroy; `Board::reserve_id` hands +//! out an id no object has. The puzzle engine still knows nothing of cards: SpellGrid +//! (`../spellgrid.rs`) uses these. + +mod grid; +mod level; +mod movement; +mod parse; +mod resolve; +mod turn; +mod word; + +pub use grid::{Board, Kind, LevelCard, LevelDef, LevelObject, Object, ObjectId}; +pub use level::{remove_all, Level, FLOAT, WIN, YOU}; +pub use movement::{Dir, Plan, Planner, Response, Step}; +pub use parse::{parse, Rule, BASE_RULES}; +pub use resolve::resolve; +pub use turn::{Hazard, Phase, TURN}; +pub use word::{lex, Lexicon, Role, Selects, Token, WordDef}; diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs new file mode 100644 index 0000000..4d0a381 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs @@ -0,0 +1,134 @@ +//! Movement: validate, then commit. +//! +//! A move is planned before anything on the board changes. The `Planner` asks +//! whether one object can step one tile; to answer, it runs every movement +//! response content provides for that step, and a response may ask the planner +//! to move other objects too (a push), or to destroy objects. If any response +//! says no, the planner forgets everything that step added to the plan, so a +//! failed push leaves no trace. Only a plan that succeeded is committed. +//! +//! The engine knows the board, its edge, and the order of the steps. What each +//! property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a +//! response in content, run in the order content lists them. + +use std::collections::BTreeSet; + +use super::grid::ObjectId; +use super::level::Level; + +/// One of the four directions a move can take. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dir { + Up, + Down, + Left, + Right, +} + +impl Dir { + /// One tile in this direction, as `(dx, dy)`; `y` grows down. + pub fn delta(self) -> (i32, i32) { + match self { + Dir::Up => (0, -1), + Dir::Down => (0, 1), + Dir::Left => (-1, 0), + Dir::Right => (1, 0), + } + } +} + +/// One object stepping one tile, from `from` to `to`, in direction `dir`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Step { + pub mover: ObjectId, + pub from: (i32, i32), + pub to: (i32, i32), + pub dir: Dir, +} + +/// A movement response: content's rule for what happens when an object steps. +/// It returns `false` to block the step. It may plan other moves or destroy +/// objects through the planner. +pub type Response = fn(&mut Planner<'_>, Step) -> bool; + +/// What a move will do: the objects that step one tile, and the objects destroyed. +/// A destroyed object doesn't step, even if it was pushed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Plan { + pub moving: BTreeSet, + pub destroyed: BTreeSet, +} + +/// Plans one move on a board it only reads. +pub struct Planner<'a> { + level: &'a Level, + dir: Dir, + plan: Plan, +} + +impl<'a> Planner<'a> { + pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> { + Planner { + level, + dir, + plan: Plan::default(), + } + } + + /// Does object `id` have `property`, under the rules in force before the move? + pub fn has(&self, id: ObjectId, property: &str) -> bool { + self.level.has_property(id, property) + } + + /// The objects that will still be on tile `at` when the plan so far is + /// committed: those there now, less those planned to move away or be destroyed. + /// Oldest first. + pub fn staying_at(&self, (x, y): (i32, i32)) -> Vec { + self.level + .board() + .at(x, y) + .map(|o| o.id) + .filter(|id| !self.plan.moving.contains(id) && !self.plan.destroyed.contains(id)) + .collect() + } + + /// Plan object `id`'s destruction. + pub fn destroy(&mut self, id: ObjectId) { + self.plan.destroyed.insert(id); + } + + /// Plan object `id` stepping one tile in the move's direction, with whatever + /// the responses add. Returns whether it can. If it can't, the plan is left as + /// it was before the call. An object already planned to move, or to be + /// destroyed, is out of the way: the answer is yes, and nothing is added. + pub fn try_move(&mut self, id: ObjectId) -> bool { + if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { + return true; + } + let level = self.level; + let o = level.board().get(id); + let (dx, dy) = self.dir.delta(); + let to = (o.x + dx, o.y + dy); + // The edge of the level blocks every step (the course's choice; see the lesson). + if !level.board().in_bounds(to.0, to.1) { + return false; + } + let before = self.plan.clone(); + self.plan.moving.insert(id); + let step = Step { + mover: id, + from: (o.x, o.y), + to, + dir: self.dir, + }; + let allowed = level.responses().iter().all(|respond| respond(self, step)); + if !allowed { + self.plan = before; + } + allowed + } + + pub fn into_plan(self) -> Plan { + self.plan + } +} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/parse.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/parse.rs new file mode 100644 index 0000000..ddcab03 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/parse.rs @@ -0,0 +1,166 @@ +//! The parser: words on the grid become rules. +//! +//! Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`, +//! read left to right in a row and top to bottom in a column. The parser starts at +//! every `IS`, collects the subjects before it and the complements after it, and +//! emits one rule per pair: AND distributes over both sides. A tile holding several +//! words contributes each of them, so stacked text yields every possible rule. +//! +//! `NOT` before a complement negates that one complement. `NOT` before a subject is +//! not in the grammar: the run of subjects stops there and that subject is dropped. +//! +//! The parser only reads; which rules win is the resolution pass (`resolve`). + +use std::collections::BTreeMap; +use std::fmt; + +use super::grid::{Board, ObjectId}; +use super::word::{lex, Lexicon, Role, Token, AND, IS, NOT, TEXT}; + +/// One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`. +/// `text` is the text objects that spell it (subject, `IS`, `NOT` if any, +/// complement), or empty for a base rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub subject: String, + pub complement: String, + pub complement_role: Role, + pub negated: bool, + pub text: Vec, +} + +impl Rule { + /// `X IS Y` where `Y` is a noun: every X is to become a Y. + pub fn is_conversion(&self) -> bool { + self.complement_role == Role::Noun + } + + /// `X IS X` (or `X IS NOT X`). + pub fn is_identity(&self) -> bool { + self.subject == self.complement + } + + /// Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. + pub fn negates(&self, other: &Rule) -> bool { + self.negated + && !other.negated + && self.subject == other.subject + && self.complement == other.complement + } +} + +impl fmt::Display for Rule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let not = if self.negated { " NOT" } else { "" }; + write!(f, "{} {IS}{not} {}", self.subject, self.complement) + } +} + +/// Rules in force with no text: the game's base rules (`(subject, property)`). +pub const BASE_RULES: &[(&str, &str)] = &[(TEXT, "PUSH")]; + +type Tiles = BTreeMap<(i32, i32), Vec>; + +/// A word in a run, with the `NOT` in front of it if there is one. +struct Word<'a> { + not: Option<&'a Token>, + token: &'a Token, +} + +/// Every rule the board spells, base rules first, negated rules included. A rule +/// spelled by two different sets of text objects appears twice. +pub fn parse(board: &Board, lexicon: &Lexicon) -> Vec { + let tiles = lex(board, lexicon); + let mut rules: Vec = BASE_RULES + .iter() + .map(|&(subject, property)| Rule { + subject: subject.to_string(), + complement: property.to_string(), + complement_role: Role::Property, + negated: false, + text: vec![], + }) + .collect(); + for (&(x, y), tokens) in &tiles { + for is in tokens.iter().filter(|t| t.word == IS) { + for (dx, dy) in [(1, 0), (0, 1)] { + let subjects = subjects(&tiles, (x - dx, y - dy), (-dx, -dy)); + let complements = complements(&tiles, (x + dx, y + dy), (dx, dy)); + for s in &subjects { + for c in &complements { + let mut text = vec![s.id, is.id]; + text.extend(c.not.map(|n| n.id)); + text.push(c.token.id); + rules.push(Rule { + subject: s.word.clone(), + complement: c.token.word.clone(), + complement_role: c.token.role, + negated: c.not.is_some(), + text, + }); + } + } + } + } + } + rules +} + +fn words_at(tiles: &Tiles, p: (i32, i32)) -> &[Token] { + tiles.get(&p).map(Vec::as_slice).unwrap_or(&[]) +} + +fn holds(tiles: &Tiles, p: (i32, i32), word: &str) -> bool { + words_at(tiles, p).iter().any(|t| t.word == word) +} + +fn ahead(p: (i32, i32), step: (i32, i32)) -> (i32, i32) { + (p.0 + step.0, p.1 + step.1) +} + +/// The run of subjects before an `IS`, walking backwards from `at`: the nouns at +/// `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT` +/// before a noun ends the run without it (a negated subject is not in the grammar). +fn subjects(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec<&Token> { + let before = ahead(at, step); + if holds(tiles, before, NOT) { + return vec![]; + } + let mut found: Vec<&Token> = words_at(tiles, at) + .iter() + .filter(|t| t.role == Role::Noun) + .collect(); + if !found.is_empty() && holds(tiles, before, AND) { + found.extend(subjects(tiles, ahead(before, step), step)); + } + found +} + +/// The run of complements after an `IS`, walking forwards from `at`: the nouns +/// and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it, +/// negated; then, if the next tile holds an AND, the run beyond it. +fn complements(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec> { + let fits = |t: &&Token| matches!(t.role, Role::Noun | Role::Property); + let mut found = vec![]; + // Plain words here, and negated words one tile on: a stack may hold both. + let mut branches: Vec<(Option<&Token>, (i32, i32))> = vec![(None, at)]; + for not in words_at(tiles, at).iter().filter(|t| t.word == NOT) { + branches.push((Some(not), ahead(at, step))); + } + for (not, p) in branches { + let words: Vec = words_at(tiles, p) + .iter() + .filter(fits) + .map(|token| Word { not, token }) + .collect(); + if words.is_empty() { + continue; + } + found.extend(words); + let and_at = ahead(p, step); + if holds(tiles, and_at, AND) { + found.extend(complements(tiles, ahead(and_at, step), step)); + } + } + found +} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/resolve.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/resolve.rs new file mode 100644 index 0000000..6965b57 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/resolve.rs @@ -0,0 +1,33 @@ +//! The resolution pass: from every rule the text spells to the rules in force. +//! +//! The parser reports what is written; this pass decides what wins, over the whole +//! rule list at once, so it doesn't matter where on the board each rule is written: +//! +//! 1. **Negation.** `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in +//! force: it keeps overriding, and `X IS NOT X` is also the record that every X is +//! to vanish (the turn removes them, Module 9). +//! 2. **Identity.** A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun): +//! X can't become anything else. It doesn't stop other nouns becoming X. Because +//! negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again. +//! +//! Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`. + +use super::parse::Rule; + +/// The rules in force, in the order they were parsed. +pub fn resolve(rules: &[Rule]) -> Vec { + let survives_negation: Vec<&Rule> = rules + .iter() + .filter(|r| !rules.iter().any(|n| n.negates(r))) + .collect(); + let locked = |subject: &str| { + survives_negation + .iter() + .any(|r| !r.negated && r.is_identity() && r.subject == subject) + }; + survives_negation + .iter() + .filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(&r.subject)) + .map(|&r| r.clone()) + .collect() +} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/turn.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/turn.rs new file mode 100644 index 0000000..a71b0f4 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/turn.rs @@ -0,0 +1,44 @@ +//! The turn (Module 9): one move of the player, as a fixed list of phases. +//! +//! The course's turn is a labelled simplification of the game's (see the lesson): +//! movement, parse, conversion, parse, hazards, win, and a last parse so that +//! the rules match the board when the turn ends. Each phase reads the rules from +//! the parse before it, so a rule that movement breaks is gone before hazards run. +//! +//! What a hazard property does (`SINK`, `HOT`/`MELT`, `DEFEAT`, ...) is a `Hazard` +//! that content provides, as with movement's responses. The engine runs them in +//! content's order and owns only the phases, `YOU`, `WIN` and float levels. + +use super::grid::ObjectId; +use super::level::Level; + +/// One phase of the turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + /// Every `YOU` object moves (Module 8), without the reparse. + Movement, + /// The rules are recompiled from the board. + Parse, + /// `X IS Y` conversions, and `X IS NOT X` deletions. + Conversion, + /// Content's hazards, in content's order. + Hazards, + /// A `YOU` object on a `WIN` one (or `WIN` itself) wins the level. + Win, +} + +/// The turn, in order. +pub const TURN: [Phase; 7] = [ + Phase::Movement, + Phase::Parse, + Phase::Conversion, + Phase::Parse, + Phase::Hazards, + Phase::Win, + Phase::Parse, +]; + +/// A hazard: content's rule for what destroys what at the end of a turn. It reads +/// the level and returns the objects to destroy; the engine destroys them all at +/// once, before the next hazard runs. An id may appear more than once. +pub type Hazard = fn(&Level) -> Vec; diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/word.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/word.rs new file mode 100644 index 0000000..398ae7b --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/word.rs @@ -0,0 +1,130 @@ +//! The lexer: text objects become words with a grammatical role. +//! +//! The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`. +//! Every other word comes from content as data: the engine never names a noun. +//! A noun also says which objects it names (`Selects`), so a meta-noun that names +//! a whole group of objects can be data too. + +use std::collections::{BTreeMap, HashMap}; + +use serde::Deserialize; + +use super::grid::{Board, Kind, ObjectId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Noun, + Property, + Operator, +} + +/// Which objects a noun names when it is the subject of a rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Selects { + /// The things of its own name: `` names every object of kind `""`. + #[default] + Named, + /// Every text object (the meta-noun `TEXT`). + Text, + /// Every object that is not text, whatever its noun. + Things, +} + +/// One entry of the word list content provides: `{ "name": "", "role": "noun" }`, +/// optionally with `"selects"` (default `"named"`). +#[derive(Debug, Clone, Deserialize)] +pub struct WordDef { + pub name: String, + pub role: Role, + #[serde(default)] + pub selects: Selects, +} + +pub const IS: &str = "IS"; +pub const AND: &str = "AND"; +pub const NOT: &str = "NOT"; +/// The meta-noun for every text object. +pub const TEXT: &str = "TEXT"; + +/// Every word the parser can read, with its role. +#[derive(Debug, Clone, Default)] +pub struct Lexicon { + roles: HashMap, + selects: 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(); + let mut selects: HashMap = words + .iter() + .map(|w| (w.name.to_uppercase(), w.selects)) + .collect(); + for op in [IS, AND, NOT] { + roles.insert(op.to_string(), Role::Operator); + } + roles.insert(TEXT.to_string(), Role::Noun); + selects.insert(TEXT.to_string(), Selects::Text); + Lexicon { roles, selects } + } + + /// `None` for a word the lexicon doesn't know: such text spells nothing. + pub fn role(&self, word: &str) -> Option { + self.roles.get(word).copied() + } + + /// Does the noun `word`, as the subject of a rule, name an object of this kind? + pub fn names(&self, word: &str, kind: &Kind) -> bool { + let selects = self.selects.get(word).copied().unwrap_or_default(); + match (selects, kind) { + (Selects::Named, Kind::Thing { noun }) => noun == word, + (Selects::Text, Kind::Text { .. }) => true, + (Selects::Things, Kind::Thing { .. }) => true, + _ => false, + } + } + + /// What a conversion into the noun `word` makes (Module 9): a thing of that + /// name, if `word` names the things of its own name. `None` for a noun that + /// names a group (`TEXT`, or `ALL` if content adds it): the course doesn't + /// convert into those. + pub fn thing_named(&self, word: &str) -> Option { + let selects = self.selects.get(word).copied().unwrap_or_default(); + (selects == Selects::Named).then(|| Kind::Thing { + noun: word.to_string(), + }) + } +} + +/// A piece of text the parser can read: which object, which word, which role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + pub id: ObjectId, + pub word: String, + pub role: Role, +} + +/// The words on the board, by tile; a tile's words in object order (oldest first). +/// Text whose word the lexicon doesn't know is left out. +pub fn lex(board: &Board, lexicon: &Lexicon) -> BTreeMap<(i32, i32), Vec> { + 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/10-spellgrid/reference/rust/src/engine/queue.rs b/course/10-spellgrid/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..4358c8f --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/queue.rs @@ -0,0 +1,314 @@ +//! The action queue: how one player action (a Sequence) resolves. +//! +//! Performing an action changes the world and produces events. Every trigger that +//! responds to those events goes into one Queue, oldest listener first, and the Queue +//! is locked. Each queued trigger then resolves completely, including everything +//! its own actions cause, before the next one starts: depth-first. +//! +//! Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the +//! Death Creation Step removes the dead, and a Death Phase resolves their +//! Deathrattles. That repeats until a step finds no new deaths. +//! +//! Module 4: a queued trigger may activate more than once. How many times is +//! decided by one routine, `activations` in `aura.rs`, from aura data on cards. +//! Nothing here knows which cards multiply which triggers. +//! +//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and +//! its actions performed before the next effect of the same ability is read, so +//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion. +//! +//! Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`, +//! read from the world, as Part II's turn is a list of phases. SpellGrid's tests run +//! the same Sequence with the list reversed, to show what the order decides. + +use super::assemble::assemble_from_pool; +use super::aura::{activations, aura_update_other}; +use super::card::{Effect, PoolFilter, TriggerOn}; +use super::death::death_creation_step; +use super::effect::actions; +use super::event::Event; +use super::world::{EntityId, PlayerId, World, Zone}; + +/// Something the engine can do. Card effects return these instead of changing the world. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Deal `amount` to each target at once: all damage first, then the reactions. + Damage { + targets: Vec, + 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, + }, + /// Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it. + SpawnText { + source: EntityId, + word: String, + }, +} + +/// A step that runs when the outermost Phase ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndOfPhaseStep { + /// The Death Creation Step (Module 3). + DeathCreation, + /// An Aura Update (Other) (Module 4). + AuraUpdate, +} + +/// The end of the outermost Phase, in Hearthstone's order: the dead leave play first, +/// then the auras are read again, so a minion that just died multiplies nothing in +/// the Death Phase that follows. +pub const END_OF_PHASE: [EndOfPhaseStep; 2] = + [EndOfPhaseStep::DeathCreation, EndOfPhaseStep::AuraUpdate]; + +/// What an effect may look at when it runs. +pub struct Ctx<'a> { + pub world: &'a World, + /// The player the effect works for. + pub owner: PlayerId, + /// The minion whose trigger is resolving; `None` for a spell. + pub source: Option, + /// 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, in the world's order +/// (`END_OF_PHASE` unless changed); returns the Death Events. Hearthstone runs an +/// Aura Update (Health/Attack) first. The course has no Health/Attack auras, so that +/// step has nothing to do and is left out. +fn end_of_outermost_phase(world: &mut World) -> Vec { + let mut deaths = Vec::new(); + for step in world.end_of_phase().to_vec() { + match step { + EndOfPhaseStep::DeathCreation => deaths = death_creation_step(world), + EndOfPhaseStep::AuraUpdate => aura_update_other(world), + } + } + deaths +} + +/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an +/// Aura Update. +pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option) -> EntityId { + let id = world.create_at(card, player, Zone::Board, at); + aura_update_other(world); + id +} + +fn perform(world: &mut World, action: Action) { + match action { + Action::Damage { + mut targets, + amount, + } => { + // Area damage: every Damage Event is created, in order of play, before any reaction. + targets.sort_by_key(|&id| world.order_of_play(id)); + let mut events = Vec::new(); + for target in targets { + world.damage(target, amount); + events.push(Event::Damaged { target, amount }); + } + react(world, &events); + } + Action::Draw { player } => world.draw(player), + Action::GainArmor { player, amount } => world.gain_armor(player, amount), + Action::GainAttack { id, amount } => world.gain_attack(id, amount), + Action::Destroy { targets } => { + for target in targets { + world.mark_destroyed(target); + } + } + Action::Summon { card, player, at } => { + summon(world, &card, player, at); + } + Action::Play { id } => { + assert!( + world.zone_of(id) == Zone::Hand, + "play: entity {id} is not in a hand" + ); + // Playing is not summoning: no Aura Update here. The played minion's own + // auras join at the end of the outermost Phase. + world.move_to(id, Zone::Board); + let played = Event::Played { id }; + world.record(played.clone()); + react(world, &[played]); + } + Action::Cast { + card, + player, + target, + } => { + let spell = world.card(&card); + assert!(spell.is_spell(), "{card:?} is not a spell"); + let effects = spell.on_cast.clone(); + world.record(Event::SpellCast { card }); + run_effects(world, &effects, player, None, target, None); + } + Action::Assemble { player, filter } => { + assemble_from_pool(world, player, &filter); + } + Action::Buff { id, attack, health } => world.buff(id, attack, health), + Action::SpawnText { source, word } => world.record(Event::TextSpawned { source, word }), + } +} + +/// Interpret and perform an ability's effects in printed order. Each effect sees the +/// world as the effects before it left it. Depth-first: each action, and every +/// reaction it causes, finishes before the next. +fn run_effects( + world: &mut World, + effects: &[Effect], + owner: PlayerId, + source: Option, + 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/10-spellgrid/reference/rust/src/engine/spellgrid.rs b/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs new file mode 100644 index 0000000..37a6974 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/spellgrid.rs @@ -0,0 +1,323 @@ +//! Part III: SpellGrid, the two engines in one level. +//! +//! Part I (the card engine) and Part II (the grid engine, `puzzle`) don't use each +//! other. This file is the only place that knows both, and they meet at three seams: +//! +//! 1. Rules become modifiers. After every reparse, the rules in force that join a +//! trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into +//! auras, and handed to Part I with `World::set_rule_auras`. A negated one +//! (`DEATHRATTLE IS NOT DOUBLE`) becomes a cap. Which words are which is content's +//! data (`SeamWords`), not code here. +//! 2. Hazards mark, the death step removes. The grid engine hands the objects a turn +//! destroys to `CardSide::destroy`. A minion among them is marked pending destroy, +//! in one Sequence, and Part I's Death Creation Step removes it. +//! 3. Grid changes reparse. While a Sequence runs, the grid doesn't change. When it +//! ends, the text its effects asked for (`TextSpawned` events) is written, the +//! minions that died (`Destroyed` events) leave the grid, and the rules are parsed +//! again, before anything else happens. +//! +//! One id space: a minion's id is its grid object's id. Every other card (a hero, a +//! card in hand) gets an id from the same counter that no object will have. + +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +use super::aura::aura_update_other; +use super::card::{Aura, CardLibrary, CardType, Stacking, TriggerOn}; +use super::event::Event; +use super::puzzle::{Dir, Hazard, Kind, Level, LevelDef, ObjectId, Response, Rule, WordDef}; +use super::queue::{run_sequence, Action}; +use super::world::{EntityId, World, Zone}; + +/// A multiplier word's effect: `"DOUBLE": { "times": 2, "stacking": "max" }`. +#[derive(Debug, Clone, Copy, Deserialize)] +pub struct Multiplier { + pub times: u32, + pub stacking: Stacking, +} + +/// The words that cross the seam, from content: nouns that name a trigger +/// (`"DEATHRATTLE": "deathrattle"`) and properties that multiply one. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SeamWords { + pub trigger_words: HashMap, + pub multiplier_words: HashMap, +} + +/// Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a +/// multiplier word) multiplies T's triggers; `T IS NOT M` caps them at one activation, +/// so it cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`. +pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec { + let mut auras = Vec::new(); + for rule in rules { + let (Some(&trigger), Some(&m)) = ( + words.trigger_words.get(&rule.subject), + words.multiplier_words.get(&rule.complement), + ) else { + continue; + }; + auras.push(if rule.negated { + Aura::TriggerCap { trigger, times: 1 } + } else { + Aura::TriggerMultiplier { + trigger, + times: m.times, + stacking: m.stacking, + } + }); + } + auras +} + +/// A SpellGrid level in play: a grid and a game, kept in step. Ids are grid ids (see above). +pub struct SpellGrid { + level: Level, + side: CardSide, +} + +/// Part I's half: the game, and which entity is which id. Kept apart from the level so +/// that the level can lend itself to the destroy function while this half changes. +struct CardSide { + world: World, + /// Every entity's id. + ids: HashMap, + /// The entity behind each id (a card's old id still finds it after it is played). + entities: HashMap, + seam_words: SeamWords, +} + +impl SpellGrid { + /// The level's objects; its `cards` as minions in play for player 0, each a grid + /// object named by the card's noun; its `hand` in player 0's hand. + pub fn new( + def: &LevelDef, + library: CardLibrary, + words: &[WordDef], + responses: Vec, + hazards: Vec, + seam_words: SeamWords, + ) -> SpellGrid { + let mut level = Level::new(def, words, responses, hazards); + let mut side = CardSide { + world: World::new(library), + ids: HashMap::new(), + entities: HashMap::new(), + seam_words, + }; + for c in &def.cards { + let kind = side.noun_of(&c.card); + let id = level.board_mut().place(kind, c.x, c.y); + let entity = side.world.create(&c.card, 0, Zone::Board); + side.link(entity, id); + } + // Every minion is in play: one Aura Update, as for a summon. + aura_update_other(&mut side.world); + for card in &def.hand { + side.world.create(card, 0, Zone::Hand); + } + side.link_the_rest(&mut level); + side.grid_changed(&mut level); + SpellGrid { level, side } + } + + pub fn level(&self) -> &Level { + &self.level + } + + pub fn world(&self) -> &World { + &self.side.world + } + + pub fn world_mut(&mut self) -> &mut World { + &mut self.side.world + } + + // ---- Ids ---- + + /// The id of entity `entity`. + pub fn id_of(&self, entity: EntityId) -> ObjectId { + self.side.ids[&entity] + } + + /// The entity behind `id`. Panics if `id` is not a card's. + pub fn entity(&self, id: ObjectId) -> EntityId { + self.side.entity(id) + } + + /// The id of the minion on tile (x, y), if there is one. + pub fn minion_at(&self, x: i32, y: i32) -> Option { + self.level + .board() + .at(x, y) + .map(|o| o.id) + .find(|id| self.side.entities.contains_key(id)) + } + + // ---- What the player does ---- + + /// Add an object or a piece of text, then reparse. + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { + let id = self.level.place(kind, x, y); + self.side.grid_changed(&mut self.level); + id + } + + /// One whole turn (Module 9); what it destroys goes through seam 2. + pub fn step(&mut self, dir: Dir) { + let side = &mut self.side; + self.level + .step_with(dir, &mut |level, ids| side.destroy(level, ids)); + self.side.grid_changed(&mut self.level); + } + + /// Movement only (Module 8), then a reparse. + pub fn move_dir(&mut self, dir: Dir) { + let side = &mut self.side; + self.level + .move_you_with(dir, &mut |level, ids| side.destroy(level, ids)); + self.side.grid_changed(&mut self.level); + } + + /// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. + pub fn cast(&mut self, card: &str, target: Option) { + let in_hand = self.side.from_hand(card, CardType::Spell); + self.side.world.move_to(in_hand, Zone::Graveyard); + let target = target.map(|id| self.side.entity(id)); + let action = Action::Cast { + card: card.to_string(), + player: 0, + target, + }; + self.side.sequence(&mut self.level, action); + } + + /// Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence. + /// Returns its new id. + pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId { + let in_hand = self.side.from_hand(card, CardType::Minion); + assert!( + self.level.board().at(x, y).next().is_none(), + "({x}, {y}) is not empty" + ); + let kind = self.side.noun_of(card); + let id = self.level.board_mut().place(kind, x, y); + self.side.link(in_hand, id); + self.side + .sequence(&mut self.level, Action::Play { id: in_hand }); + id + } +} + +impl CardSide { + fn entity(&self, id: ObjectId) -> EntityId { + *self + .entities + .get(&id) + .unwrap_or_else(|| panic!("{id} is not a card")) + } + + fn link(&mut self, entity: EntityId, id: ObjectId) { + self.ids.insert(entity, id); + self.entities.insert(id, entity); + } + + /// Give every entity that has no id one that no object will have. + fn link_the_rest(&mut self, level: &mut Level) { + for entity in 0..self.world.entity_count() { + if !self.ids.contains_key(&entity) { + let id = level.board_mut().reserve_id(); + self.link(entity, id); + } + } + } + + fn noun_of(&self, card: &str) -> Kind { + let def = &self.world.card(card).def; + match (&def.noun, def.card_type) { + (Some(noun), CardType::Minion) => Kind::parse(&noun.to_lowercase()), + _ => panic!("{card:?} is not a minion with a noun"), + } + } + + fn from_hand(&self, card: &str, card_type: CardType) -> EntityId { + let in_hand = self + .world + .zone(0, Zone::Hand) + .iter() + .copied() + .find(|&id| self.world.card_name(id) == card) + .unwrap_or_else(|| panic!("{card:?} is not in the hand")); + assert!( + self.world.card(card).def.card_type == card_type, + "{card:?} is not a {card_type:?}" + ); + in_hand + } + + /// Seam 2. Objects that aren't cards leave the board. Minions are marked pending + /// destroy, in one Sequence, under the rules as they stand now. + fn destroy(&mut self, level: &mut Level, ids: &[ObjectId]) { + let mut seen = HashSet::new(); + let mut minions = Vec::new(); + for &id in ids { + if !seen.insert(id) || level.board().find(id).is_none() { + continue; + } + match self.entities.get(&id) { + Some(&entity) => minions.push(entity), + None => level.board_mut().remove(id), + } + } + if minions.is_empty() { + return; + } + self.sync_rules(level); + self.sequence(level, Action::Destroy { targets: minions }); + } + + /// Run one Sequence in Part I, then seam 3: apply its grid changes and reparse. + fn sequence(&mut self, level: &mut Level, action: Action) { + let from = self.world.log().len(); + run_sequence(&mut self.world, action); + let events = self.world.log()[from..].to_vec(); + // Text first, while every minion that wrote it is still on its tile. A minion + // with no id (summoned by this Sequence) is not on the grid: nowhere to write. + for event in &events { + if let Event::TextSpawned { source, word } = event { + let tile = self + .ids + .get(source) + .and_then(|&id| level.board().find(id)) + .map(|o| (o.x, o.y)); + if let Some((x, y)) = tile { + let text = Kind::Text { word: word.clone() }; + level.board_mut().place(text, x, y); + } + } + } + for event in &events { + if let Event::Destroyed { id, .. } = event { + if let Some(&object) = self.ids.get(id) { + level.board_mut().remove(object); + } + } + } + self.link_the_rest(level); + self.grid_changed(level); + } + + /// The grid changed: reparse it, then seam 1. + fn grid_changed(&mut self, level: &mut Level) { + level.reparse(); + self.sync_rules(level); + } + + /// Seam 1: compile the rules in force into auras for Part I, then run an Aura Update. + fn sync_rules(&mut self, level: &Level) { + let auras = rule_auras(level.active_rules(), &self.seam_words); + self.world.set_rule_auras(auras); + aura_update_other(&mut self.world); + } +} diff --git a/course/10-spellgrid/reference/rust/src/engine/world.rs b/course/10-spellgrid/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..01401d4 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/engine/world.rs @@ -0,0 +1,411 @@ +//! Entities, zones, order of play, stats and the event log: the game's state. +//! The rules for resolving actions live in `queue.rs`. + +use super::card::{Aura, Card, CardDef, CardLibrary}; +use super::event::Event; +use super::queue::{EndOfPhaseStep, END_OF_PHASE}; + +/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone. +pub type EntityId = u32; +pub type PlayerId = u8; + +/// Starting Health of each hero. +pub const HERO_HEALTH: i32 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Zone { + Deck, + Hand, + Board, + Graveyard, +} + +#[derive(Debug)] +struct Entity { + card: String, + owner: PlayerId, + zone: Zone, + /// Stamped each time the entity enters the board; `None` until it first does. + order_of_play: Option, + 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, + /// Module 10: auras that no minion carries, for every player's minions. SpellGrid + /// compiles them from the rules on its grid. The next Aura Update brings them in. + rule_auras: Vec, + /// Module 10: the steps at the end of the outermost Phase, in order. + end_of_phase: 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(), + rule_auras: Vec::new(), + end_of_phase: END_OF_PHASE.to_vec(), + }; + for owner in 0..2 { + let id = world.entities.len() as EntityId; + world.next_order_of_play += 1; + world.entities.push(Entity { + card: "Hero".to_string(), + owner, + zone: Zone::Board, + order_of_play: Some(world.next_order_of_play), + attack: 0, + health: HERO_HEALTH, + pending_destroy: false, + }); + // In play, but not on the board list: the board holds minions only. + world.sides[owner as usize].hero = id; + } + world + } + + /// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board). + pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId { + self.create_at(card, owner, zone, None) + } + + /// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the + /// zone's length), or at the end if `at` is `None`. + pub fn create_at( + &mut self, + card: &str, + owner: PlayerId, + zone: Zone, + at: Option, + ) -> 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 + } + + /// Auras from outside the cards (Module 10), as last set. + pub fn rule_auras(&self) -> &[Aura] { + &self.rule_auras + } + + /// The steps at the end of the outermost Phase, in order. + pub fn end_of_phase(&self) -> &[EndOfPhaseStep] { + &self.end_of_phase + } + + /// How many entities exist: ids run from 0 to this, exclusive. + pub fn entity_count(&self) -> u32 { + self.entities.len() as u32 + } + + pub fn log(&self) -> &[Event] { + &self.log + } + + // ---- Changes: each one records its event ---------------------------- + + pub fn record(&mut self, event: Event) { + self.log.push(event); + } + + /// Add a card to the library, or replace the card with the same name. + pub fn register(&mut self, def: CardDef) { + self.library.register(def); + } + + /// Add a card made by Assemble (see `assemble.rs`). + pub fn register_assembled(&mut self, def: CardDef) { + self.library.register_assembled(def); + } + + pub fn set_chooser(&mut self, chooser: Chooser) { + self.chooser = chooser; + } + + /// Ask the chooser to pick one of `options` (not empty). Panics if it answers + /// something that isn't an option. + pub fn choose(&mut self, kind: &str, options: &[String]) -> String { + let answer = (self.chooser)(kind, options); + assert!( + options.contains(&answer), + "chooser answered {answer:?} for {kind:?}; the options were {options:?}" + ); + answer + } + + /// Replace the auras from outside the cards. They take effect at the next Aura Update. + pub fn set_rule_auras(&mut self, auras: Vec) { + self.rule_auras = auras; + } + + /// Change the order of the steps at the end of the outermost Phase. + pub fn set_end_of_phase(&mut self, steps: Vec) { + self.end_of_phase = steps; + } + + /// 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/10-spellgrid/reference/rust/src/harness.rs b/course/10-spellgrid/reference/rust/src/harness.rs new file mode 100644 index 0000000..651fbb2 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/harness.rs @@ -0,0 +1,446 @@ +//! Test harness: the thin adapter the course tests call. It translates each +//! call into engine operations and holds no rules of its own. + +use crate::content; +use crate::engine::{ + self, puzzle, run_sequence, summon, Action, CardLibrary, Event, SpellGrid as Grid, World, + END_OF_PHASE, +}; + +pub use crate::engine::{AssembleError, CardDef, Half, Zone}; + +pub type Id = u32; +pub type Player = u8; + +#[derive(Debug, Default, Clone)] +pub struct GameOpts { + pub decks: Option<[Vec; 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, + // SpellGrid writes the text on the grid; the course's log doesn't show the request. + Event::TextSpawned { .. } => return None, + }) + } +} + +pub struct Game { + world: World, +} + +pub fn new_game(opts: GameOpts) -> Game { + let mut world = World::new(CardLibrary::new(content::cards())); + if let Some(decks) = opts.decks { + for (player, deck) in decks.iter().enumerate() { + for card in deck { + world.create(card, player as Player, Zone::Deck); + } + } + } + Game { world } +} + +impl Game { + /// Put `card` at the right end of `player`'s board. + pub fn summon(&mut self, card: &str, player: Player) -> Id { + summon(&mut self.world, card, player, None) + } + pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Hand) + } + pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Deck) + } + /// Play a card from hand, as one Sequence: it enters the right end of the board, then + /// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.) + pub fn play(&mut self, hand_id: Id) { + run_sequence(&mut self.world, Action::Play { id: hand_id }); + } + pub fn zone_of(&self, id: Id) -> Zone { + self.world.zone_of(id) + } + /// Board ids, left to right. + pub fn board(&self, player: Player) -> Vec { + 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::{Dir, LevelCard, LevelDef, LevelObject}; + +/// A Baba Is You level in play (Part II). +pub struct Baba { + level: puzzle::Level, +} + +/// A level from a level definition; its text is parsed straight away. +pub fn new_baba(level: LevelDef) -> Baba { + Baba { + level: puzzle::Level::new( + &level, + &content::words(), + content::movement(), + content::hazards(), + ), + } +} + +impl Baba { + // ---- Module 6 ---- + + /// Add an object (`"baba"`) or a piece of text (`"text:BABA"`) at (x, y). + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id { + self.level.place(kind, x, y) + } + + /// Every distinct rule the text spells, before resolution, normalised + /// (`"BABA IS YOU"`, `"BABA IS NOT YOU"`) and sorted. + pub fn rules(&self) -> Vec { + normalised(self.level.rules()) + } + + // ---- Module 7 ---- + + /// Every distinct rule in force after resolution, normalised and sorted. + pub fn active_rules(&self) -> Vec { + normalised(self.level.active_rules()) + } + + /// Does object `id` have the property `prop` under the rules in force? + pub fn has_property(&self, id: Id, prop: &str) -> bool { + self.level.has_property(id, prop) + } + + // ---- Module 8 ---- + + /// Move every `YOU` object one tile in `dir`, pushing what it pushes, then + /// reparse the rules. Movement only: no conversion, hazard or win (Module 9). + pub fn move_dir(&mut self, dir: Dir) { + self.level.move_you(dir); + } + + /// Where object `id` is, or `None` once it has been destroyed. + pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> { + self.level.board().find(id).map(|o| (o.x, o.y)) + } + + /// The kinds of the objects on tile (x, y), as placed (`"baba"`, `"text:ROCK"`), sorted. + pub fn kinds_at(&self, x: i32, y: i32) -> Vec { + let mut kinds: Vec = self + .level + .board() + .at(x, y) + .map(|o| o.kind.to_string()) + .collect(); + kinds.sort(); + kinds + } + + // ---- Module 9 ---- + + /// Play one whole turn: movement, parse, conversion, parse, hazards, win + /// (and a last parse, so the rules match the board). + pub fn step(&mut self, dir: Dir) { + self.level.step(dir); + } + + /// Has a turn found a `YOU` object on a `WIN` one (or `WIN` itself)? Stays true. + pub fn won(&self) -> bool { + self.level.won() + } + + /// Is no object `YOU`? + pub fn lost(&self) -> bool { + self.level.lost() + } +} + +fn normalised(rules: &[puzzle::Rule]) -> Vec { + let mut rules: Vec = rules.iter().map(|r| r.to_string()).collect(); + rules.sort(); + rules.dedup(); + rules +} + +// ======== Part III: SpellGrid ======== + +/// A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards. +/// Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.rs`). +pub struct SpellGrid { + grid: Grid, +} + +/// A SpellGrid level; `extra` cards are added to the course's cards, replacing any +/// with the same name. +pub fn new_spell_grid(level: LevelDef, extra: Vec) -> SpellGrid { + let mut library = CardLibrary::new(content::cards()); + for def in extra { + library.register(def); + } + SpellGrid { + grid: Grid::new( + &level, + library, + &content::words(), + content::movement(), + content::hazards(), + content::seams(), + ), + } +} + +impl SpellGrid { + // ---- Part II, as on `Baba` ---- + + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> Id { + self.grid.place(kind, x, y) + } + pub fn rules(&self) -> Vec { + normalised(self.grid.level().rules()) + } + pub fn active_rules(&self) -> Vec { + normalised(self.grid.level().active_rules()) + } + pub fn has_property(&self, id: Id, prop: &str) -> bool { + self.grid.level().has_property(id, prop) + } + pub fn move_dir(&mut self, dir: Dir) { + self.grid.move_dir(dir); + } + pub fn pos_of(&self, id: Id) -> Option<(i32, i32)> { + self.grid.level().board().find(id).map(|o| (o.x, o.y)) + } + pub fn kinds_at(&self, x: i32, y: i32) -> Vec { + let mut kinds: Vec = self + .grid + .level() + .board() + .at(x, y) + .map(|o| o.kind.to_string()) + .collect(); + kinds.sort(); + kinds + } + pub fn step(&mut self, dir: Dir) { + self.grid.step(dir); + } + pub fn won(&self) -> bool { + self.grid.level().won() + } + pub fn lost(&self) -> bool { + self.grid.level().lost() + } + + // ---- Module 10 ---- + + /// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. + pub fn cast(&mut self, card: &str, target: Option) { + self.grid.cast(card, target); + } + /// Play a minion from player 0's hand onto the empty tile (x, y); returns its id. + pub fn play(&mut self, card: &str, x: i32, y: i32) -> Id { + self.grid.play(card, x, y) + } + pub fn health(&self, id: Id) -> i32 { + self.grid.world().health(self.grid.entity(id)) + } + pub fn hero(&self, player: Player) -> Id { + self.grid.id_of(self.grid.world().hero(player)) + } + pub fn name_of(&self, id: Id) -> String { + self.grid + .world() + .card_name(self.grid.entity(id)) + .to_string() + } + /// The minion on tile (x, y), if any. + pub fn minion_at(&self, x: i32, y: i32) -> Option { + self.grid.minion_at(x, y) + } + /// The card names in player 0's hand, in order. + pub fn hand(&self) -> Vec { + let world = self.grid.world(); + world + .zone(0, Zone::Hand) + .iter() + .map(|&id| world.card_name(id).to_string()) + .collect() + } + /// Part I's log, with grid ids. + pub fn log(&self) -> Vec { + let id = |entity: Id| self.grid.id_of(entity); + self.grid + .world() + .log() + .iter() + .filter_map(GameEvent::from_engine) + .map(|event| match event { + GameEvent::Summoned { id: e } => GameEvent::Summoned { id: id(e) }, + GameEvent::Destroyed { id: e } => GameEvent::Destroyed { id: id(e) }, + GameEvent::Drew { player, id: e } => GameEvent::Drew { player, id: id(e) }, + GameEvent::StatsChanged { + id: e, + attack, + health, + } => GameEvent::StatsChanged { + id: id(e), + attack, + health, + }, + GameEvent::Damaged { target, amount } => GameEvent::Damaged { + target: id(target), + amount, + }, + GameEvent::Triggered { source, trigger } => GameEvent::Triggered { + source: id(source), + trigger, + }, + other => other, + }) + .collect() + } + pub fn clear_log(&mut self) { + self.grid.world_mut().clear_log(); + } + /// Who makes Assemble's choices (`"base"`, `"donor"`, `"half"`). + pub fn set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static) { + self.grid.world_mut().set_chooser(Box::new(chooser)); + } + /// Test hook: the naive engine reads the auras before the dead leave play, the + /// same end-of-Phase steps reversed. + pub fn with_naive_aura_timing(&mut self) { + let mut steps = END_OF_PHASE.to_vec(); + steps.reverse(); + self.grid.world_mut().set_end_of_phase(steps); + } +} diff --git a/course/10-spellgrid/reference/rust/src/lib.rs b/course/10-spellgrid/reference/rust/src/lib.rs new file mode 100644 index 0000000..0f7aca6 --- /dev/null +++ b/course/10-spellgrid/reference/rust/src/lib.rs @@ -0,0 +1,4 @@ +pub mod cli; +pub mod content; +pub mod engine; +pub mod harness; diff --git a/course/10-spellgrid/reference/rust/tests/cli.rs b/course/10-spellgrid/reference/rust/tests/cli.rs new file mode 100644 index 0000000..cfc9f24 --- /dev/null +++ b/course/10-spellgrid/reference/rust/tests/cli.rs @@ -0,0 +1,66 @@ +// The SpellGrid command line, played from a script (the reference's own check; not a course test). +use std::cell::RefCell; +use std::io::{Cursor, Write}; +use std::rc::Rc; + +use engine::cli::{run, Outcome}; + +/// Collects what the CLI prints. +#[derive(Clone, Default)] +struct Screen(Rc>>); + +impl Write for Screen { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn play(level: &str, input: &[&str]) -> (Outcome, String) { + let screen = Screen::default(); + let input = Box::new(Cursor::new(input.join("\n") + "\n")); + let outcome = run(level, input, Box::new(screen.clone())); + let out = String::from_utf8(screen.0.borrow().clone()).unwrap(); + (outcome, out) +} + +#[test] +fn cli_wins_level_1() { + let input = ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"]; + let (outcome, out) = play(include_str!("../levels/l1-double-tap.json"), &input); + assert_eq!(outcome, Outcome::Won); + assert!(out.contains("DEA IS DOU")); + assert!(out.contains("You win!")); +} + +#[test] +fn cli_wins_level_2_with_choices_and_play() { + let input = [ + "c Graft", + "Ember Moth", + "Rune Scribe", + "bottom", + "c Ember Moth (Assembled) 4 3", + "a", + "a", + ]; + let (outcome, out) = play(include_str!("../levels/l2-spliced-corpse.json"), &input); + assert_eq!(outcome, Outcome::Won); + assert!(out.contains("Choose the base")); + assert!(out.contains("Hand: Ember Moth (Assembled)")); +} + +#[test] +fn cli_reports_errors_and_quits() { + let input = ["c Spark", "x", "q"]; + let (outcome, out) = play( + include_str!("../levels/l3-simultaneous-paradox.json"), + &input, + ); + assert_eq!(outcome, Outcome::Quit); + assert!(out.contains("\"Spark\" is not in the hand")); + assert!(out.contains("Commands:")); +} diff --git a/course/10-spellgrid/reference/ts/.course.json b/course/10-spellgrid/reference/ts/.course.json new file mode 100644 index 0000000..3c77d32 --- /dev/null +++ b/course/10-spellgrid/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "10" } diff --git a/course/10-spellgrid/reference/ts/.gitignore b/course/10-spellgrid/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/10-spellgrid/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/10-spellgrid/reference/ts/README.md b/course/10-spellgrid/reference/ts/README.md new file mode 100644 index 0000000..12687b6 --- /dev/null +++ b/course/10-spellgrid/reference/ts/README.md @@ -0,0 +1,20 @@ +# Module 10 reference engine (TypeScript) + +The course's own engine as of the end of Module 10: Part I's card engine and Part II's grid engine, joined by SpellGrid. Every test from Modules 1–9 still passes. Module 9's reference was the starting point, so a diff between the two shows what this module adds. + +- `cards/` and `levels/` (at the package root; not in this directory): copies of `course/cards/` and `course/10-spellgrid/levels/`, staged by `tools/verify-references.mjs`. +- `src/engine/spellgrid.ts` (new): the only file that imports both engines. `SpellGrid` keeps a `World` and a `puzzle.Level` in step through the three seams: `ruleAuras` (rules in force → auras), the destroy function (hazard victims → one `destroy` Sequence → the Death Creation Step), and `sequence` (after each Sequence: write `TextSpawned` text, take dead minions off the grid, reparse). It also keeps one id space: a minion's id is its grid object's id. +- Part I (`src/engine/`, outside `puzzle/`): + - `queue.ts`: `END_OF_PHASE` (`["deathCreation", "auraUpdate"]`) is the list of steps at the end of the outermost Phase, read from the world. The `spawnText` action records a `TextSpawned` event. + - `world.ts`: `setRuleAuras`/`ruleAuras` (auras that no minion carries), `setEndOfPhase`/`endOfPhase`, `entityCount`. + - `aura.ts`: an Aura Update adds the rule auras for both players; `activations` applies a `triggerCap`. + - `card.ts`: the `triggerCap` aura (not in the card format: only SpellGrid makes it). + - `effect.ts`: `spawnText` is an action, not an error. `event.ts`: `TextSpawned`. +- Part II (`src/engine/puzzle/`): + - `level.ts`: `Destroy` and `removeAll`. `step` and `moveYou` take an optional `Destroy`, which movement, conversion (`X IS NOT X`) and hazards hand their victims to. `reparse` is public. + - `grid.ts`: `Board.reserveId`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid). +- `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. `index.ts` now reads `cards/` with `node:fs`, so the command line, which runs without Vite, reads the same cards. +- `src/harness.ts`: Module 10 adds `newSpellGrid` and the `SpellGrid` interface, including the test hook `withNaiveAuraTiming()`, which reverses `END_OF_PHASE` for one game. +- `src/cli.ts` (new): the command line, `npx tsx src/cli.ts levels/l1-double-tap.json` from a staged workspace (it needs `cards/` and `levels/`). `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.test.ts` plays it from a script; it is the reference's own check, not a course test. + +Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/course/10-spellgrid/reference/ts/package-lock.json b/course/10-spellgrid/reference/ts/package-lock.json new file mode 100644 index 0000000..7976b9f --- /dev/null +++ b/course/10-spellgrid/reference/ts/package-lock.json @@ -0,0 +1,2018 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "systemic-design-workspace", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.20.5", + "tsx": "^4.23.15", + "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-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "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/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "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/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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/10-spellgrid/reference/ts/package.json b/course/10-spellgrid/reference/ts/package.json new file mode 100644 index 0000000..de262b9 --- /dev/null +++ b/course/10-spellgrid/reference/ts/package.json @@ -0,0 +1,16 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "tsx": "^4.23.15", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/course/10-spellgrid/reference/ts/src/cli.ts b/course/10-spellgrid/reference/ts/src/cli.ts new file mode 100644 index 0000000..47f9b8c --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/cli.ts @@ -0,0 +1,129 @@ +// SpellGrid on the command line: `npx tsx src/cli.ts levels/l1-double-tap.json`. +// +// A thin loop over the harness: it draws the level, reads a command, and calls +// `step`, `cast` or `play`. No rules live here. +// +// w a s d one turn: up, left, down, right +// c [x y] cast a spell from your hand (at the minion on (x, y), if given), +// or play a minion from your hand onto the empty tile (x, y) +// q quit +// +// Assemble's choices (Graft) are asked for one at a time: answer with a number or a name. + +import { readFileSync, readSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { newSpellGrid, type LevelDef, type SpellGrid } from "./harness"; + +const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const; + +/** One cell of the drawing: the newest object on the tile, as 3 letters; TEXT in capitals. */ +function cell(g: SpellGrid, x: number, y: number): string { + const kinds = g.kindsAt(x, y); + if (kinds.length === 0) return " . "; + // kindsAt is sorted; prefer text, which is what the player reads. + const kind = kinds.find((k) => k.startsWith("text:")) ?? kinds[0]; + const name = kind.startsWith("text:") ? kind.slice(5).toUpperCase() : kind.toLowerCase(); + return name.slice(0, 3).padEnd(3); +} + +/** The level as text: the grid, the rules, the minions and the hand. */ +export function render(g: SpellGrid, level: LevelDef): string { + const lines: string[] = []; + for (let y = 0; y < level.height; y++) { + const row: string[] = []; + for (let x = 0; x < level.width; x++) row.push(cell(g, x, y)); + lines.push(row.join(" ")); + } + lines.push(`Rules: ${g.activeRules().join(", ")}`); + const minions: string[] = []; + for (let y = 0; y < level.height; y++) { + for (let x = 0; x < level.width; x++) { + const id = g.minionAt(x, y); + if (id !== null) minions.push(`${g.nameOf(id)} (${x},${y}) ${g.health(id)} Health`); + } + } + lines.push(`Minions: ${minions.join("; ") || "none"}`); + lines.push(`Hand: ${g.hand().join(", ") || "empty"}`); + return lines.join("\n"); +} + +/** A `c` command: `c [x y]`. */ +function parseCast(args: string[]): { card: string; at?: [number, number] } { + const n = args.length; + if (n >= 3 && /^\d+$/.test(args[n - 2]) && /^\d+$/.test(args[n - 1])) { + return { card: args.slice(0, n - 2).join(" "), at: [Number(args[n - 2]), Number(args[n - 1])] }; + } + return { card: args.join(" ") }; +} + +/** + * Play `levelText` (a level in the course's format), reading commands from + * `readLine` (undefined at the end of input) and writing to `write`. Returns + * `"won"`, `"lost"` or `"quit"`. + */ +export function run(levelText: string, readLine: () => string | undefined, write: (s: string) => void): string { + const level = JSON.parse(levelText) as LevelDef; + const g = newSpellGrid(level, []); + g.setChooser((kind, options) => { + write(`Choose the ${kind} (a number or a name):\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}\n? `); + const answer = readLine()?.trim() ?? ""; + return options.includes(answer) ? answer : (options[Number(answer) - 1] ?? options[0]); + }); + for (;;) { + write(`${render(g, level)}\n`); + if (g.won()) { + write("You win!\n"); + return "won"; + } + if (g.lost()) { + write("Nothing is YOU. You lose.\n"); + return "lost"; + } + write("> "); + const line = readLine(); + if (line === undefined) return "quit"; + const [command, ...args] = line.trim().split(/\s+/); + try { + if (command === "q") return "quit"; + if (command in DIRS) g.step(DIRS[command as keyof typeof DIRS]); + else if (command === "c" && args.length > 0) { + const { card, at } = parseCast(args); + const target = at === undefined ? null : g.minionAt(...at); + if (at === undefined) g.cast(card); + else if (target !== null) g.cast(card, target); + else g.play(card, ...at); + } else write("Commands: w a s d (move), c [x y] (cast or play), q (quit)\n"); + } catch (e) { + write(`${e instanceof Error ? e.message : String(e)}\n`); + } + } +} + +/** Lines from standard input, read synchronously, so the chooser can ask mid-Sequence. */ +function stdinLines(): () => string | undefined { + const buffer = Buffer.alloc(4096); + let pending = ""; + let ended = false; + return () => { + while (!pending.includes("\n") && !ended) { + const n = readSync(0, buffer, 0, buffer.length, null); + if (n === 0) ended = true; + else pending += buffer.toString("utf8", 0, n); + } + if (pending === "" && ended) return undefined; + const end = pending.indexOf("\n"); + const line = end < 0 ? pending : pending.slice(0, end); + pending = end < 0 ? "" : pending.slice(end + 1); + return line; + }; +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const path = process.argv[2]; + if (path === undefined) { + process.stderr.write("usage: npx tsx src/cli.ts \n"); + process.exit(2); + } + const outcome = run(readFileSync(path, "utf8"), stdinLines(), (s) => process.stdout.write(s)); + process.exit(outcome === "won" ? 0 : 1); +} diff --git a/course/10-spellgrid/reference/ts/src/content/hazards.ts b/course/10-spellgrid/reference/ts/src/content/hazards.ts new file mode 100644 index 0000000..d47f73f --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/content/hazards.ts @@ -0,0 +1,48 @@ +// Part II: what destroys what at the end of a turn. The engine runs these +// hazards in this order (the order of the wiki's `block()`: SINK, then HOT/MELT, +// then DEFEAT), and destroys each one's victims together before the next runs. +// A hazard only reaches objects on the same float level: it asks the level what +// shares a tile (`Level.sharing`). A new hazard property is one more function here. + +import type { puzzle } from "../engine"; + +type Level = puzzle.Level; +type ObjectId = puzzle.ObjectId; + +/** The objects that have `property`, oldest first. */ +const withProperty = (level: Level, property: string): ObjectId[] => + level.board + .objects() + .map((o) => o.id) + .filter((id) => level.hasProperty(id, property)); + +/** A SINK object that shares its tile with anything destroys all of it, and itself. */ +function sink(level: Level): ObjectId[] { + const doomed: ObjectId[] = []; + for (const id of withProperty(level, "SINK")) { + const others = level.sharing(id); + if (others.length > 0) doomed.push(id, ...others); + } + return doomed; +} + +/** A MELT object is destroyed if it is HOT itself, or shares its tile with a HOT one. */ +function hotMelt(level: Level): ObjectId[] { + return destroyedBy(level, "MELT", "HOT"); +} + +/** A YOU object is destroyed if it is DEFEAT itself, or shares its tile with a DEFEAT one. */ +function defeat(level: Level): ObjectId[] { + return destroyedBy(level, "YOU", "DEFEAT"); +} + +/** The objects with `victim` that have `killer` too, or share a tile with one that does. */ +function destroyedBy(level: Level, victim: string, killer: string): ObjectId[] { + const deadly = (id: ObjectId) => level.hasProperty(id, killer); + return withProperty(level, victim).filter((id) => deadly(id) || level.sharing(id).some(deadly)); +} + +/** The hazards, in the order the engine runs them. */ +export function hazards(): puzzle.Hazard[] { + return [sink, hotMelt, defeat]; +} diff --git a/course/10-spellgrid/reference/ts/src/content/index.ts b/course/10-spellgrid/reference/ts/src/content/index.ts new file mode 100644 index 0000000..29ec9e0 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/content/index.ts @@ -0,0 +1,48 @@ +// Content: the cards and the Baba Is You words. Only this directory (and the card +// data in `cards/`) may name specific cards, or specific Baba Is You nouns. +// +// From Module 5 every card is a JSON file in `cards/` at the package root, in the +// course's card format (`course/schema/card.schema.json`). The functions that were +// card effects in Modules 2–4 are gone: the engine interprets the data. +// `start-module` copies the course's card files into `cards/`; add your own there. +// +// Module 10: the cards are read with Node's file system, not Vite's `import.meta.glob`, +// so the SpellGrid command line (`npx tsx src/cli.ts`), which runs without Vite, reads +// the same cards as the tests. + +import { readdirSync, readFileSync } from "node:fs"; +import type { CardDef, puzzle, SeamWords } from "../engine"; +import seamWords from "./seams.json"; +import wordList from "./words.json"; + +export { hazards } from "./hazards"; +export { responses as movement } from "./movement"; + +const CARDS = new URL("../../cards/", import.meta.url); +let loaded: CardDef[] | undefined; + +/** Every card in `cards/`, in file-name order. Read once. */ +export function cards(): CardDef[] { + loaded ??= readdirSync(CARDS) + .filter((file) => file.endsWith(".json")) + .sort() + .map((file) => JSON.parse(readFileSync(new URL(file, CARDS), "utf8")) as CardDef); + return loaded; +} + +/** + * Part II: the words of Baba Is You the parser can read, besides the engine's own + * (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one + * more entry in `words.json` in this directory. + */ +export function words(): puzzle.WordDef[] { + return wordList as puzzle.WordDef[]; +} + +/** + * Part III: the words that cross between the engines (`seams.json`). A noun that + * names a trigger, and a property that multiplies one: `DEATHRATTLE IS DOUBLE`. + */ +export function seams(): SeamWords { + return seamWords as SeamWords; +} diff --git a/course/10-spellgrid/reference/ts/src/content/movement.ts b/course/10-spellgrid/reference/ts/src/content/movement.ts new file mode 100644 index 0000000..7cf78f6 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/content/movement.ts @@ -0,0 +1,45 @@ +// Part II: what each property does when an object moves. The engine plans a move +// one step at a time and runs these responses for every step, in this order (the +// order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new +// movement property is one more response here. + +import type { puzzle } from "../engine"; + +type Planner = puzzle.Planner; +type Step = puzzle.Step; + +/** + * An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one) + * destroys both, and that object doesn't block the step. One partner per step: + * the oldest. + */ +function openShut(p: Planner, s: Step): boolean { + for (const [mine, theirs] of [ + ["OPEN", "SHUT"], + ["SHUT", "OPEN"], + ]) { + if (!p.has(s.mover, mine)) continue; + const partner = p.stayingAt(s.to).find((o) => p.has(o, theirs)); + if (partner !== undefined) { + p.destroy(partner); + p.destroy(s.mover); + return true; + } + } + return true; +} + +/** Every PUSH object on the tile ahead must step the same way, or the step fails. */ +function push(p: Planner, s: Step): boolean { + return p.stayingAt(s.to).every((o) => !p.has(o, "PUSH") || p.tryMove(o)); +} + +/** A STOP object still on the tile ahead blocks the step. */ +function stop(p: Planner, s: Step): boolean { + return !p.stayingAt(s.to).some((o) => p.has(o, "STOP")); +} + +/** The movement responses, in the order the engine runs them. */ +export function responses(): puzzle.Response[] { + return [openShut, push, stop]; +} diff --git a/course/10-spellgrid/reference/ts/src/content/seams.json b/course/10-spellgrid/reference/ts/src/content/seams.json new file mode 100644 index 0000000..656e98b --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/content/seams.json @@ -0,0 +1,4 @@ +{ + "triggerWords": { "DEATHRATTLE": "deathrattle" }, + "multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } } +} diff --git a/course/10-spellgrid/reference/ts/src/content/words.json b/course/10-spellgrid/reference/ts/src/content/words.json new file mode 100644 index 0000000..5c49029 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/content/words.json @@ -0,0 +1,31 @@ +[ + { "name": "BABA", "role": "noun" }, + { "name": "ROCK", "role": "noun" }, + { "name": "WALL", "role": "noun" }, + { "name": "FLAG", "role": "noun" }, + { "name": "LAVA", "role": "noun" }, + { "name": "KEY", "role": "noun" }, + { "name": "DOOR", "role": "noun" }, + { "name": "SKULL", "role": "noun" }, + { "name": "WATER", "role": "noun" }, + { "name": "KEG", "role": "noun" }, + { "name": "BRAMBLE", "role": "noun" }, + { "name": "HEDGE", "role": "noun" }, + { "name": "WARDEN", "role": "noun" }, + { "name": "MOTH", "role": "noun" }, + { "name": "SCRIBE", "role": "noun" }, + { "name": "KNIGHT", "role": "noun" }, + { "name": "DEATHRATTLE", "role": "noun" }, + { "name": "YOU", "role": "property" }, + { "name": "WIN", "role": "property" }, + { "name": "PUSH", "role": "property" }, + { "name": "STOP", "role": "property" }, + { "name": "OPEN", "role": "property" }, + { "name": "SHUT", "role": "property" }, + { "name": "HOT", "role": "property" }, + { "name": "MELT", "role": "property" }, + { "name": "SINK", "role": "property" }, + { "name": "DEFEAT", "role": "property" }, + { "name": "FLOAT", "role": "property" }, + { "name": "DOUBLE", "role": "property" } +] diff --git a/course/10-spellgrid/reference/ts/src/engine/assemble.ts b/course/10-spellgrid/reference/ts/src/engine/assemble.ts new file mode 100644 index 0000000..a02c62d --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/ts/src/engine/aura.ts b/course/10-spellgrid/reference/ts/src/engine/aura.ts new file mode 100644 index 0000000..050dbcc --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/aura.ts @@ -0,0 +1,55 @@ +// Auras, and the one routine that applies trigger multipliers. +// +// An aura is data on a card: a continuous effect its minion has while in play. +// The engine never asks "is that card on the board?" in the middle of resolving +// something. It takes a snapshot of the auras in force at defined points (an Aura +// Update) and every rule reads the snapshot. +// +// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for +// Health/Attack auras before the Death Creation Step, and one for every other aura +// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death +// Creation Step no longer multiplies the Deathrattles of that step's Death Phase. +// Summoning a minion also runs an Aura Update. +// +// Module 10: auras can come from outside the cards. SpellGrid compiles rules on its +// grid into auras (`World.setRuleAuras`); they join the snapshot at the next Aura +// Update, beside the minions' own, and go through the same routine. A rule's +// multiplier stacks with a card's by the same `stacking` field. A cap (`IS NOT`) +// limits the total, whatever multiplies it. + +import type { TriggerOn } from "./card"; +import type { PlayerId, World } from "./world"; + +/** + * Aura Update (Other): replace the snapshot with the auras of every minion now in + * play, and the auras from outside the cards, which apply to both players. + */ +export function auraUpdateOther(world: World): void { + const players: PlayerId[] = [0, 1]; + world.setActiveAuras([ + ...world.minionsInPlay().flatMap((id) => + world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })), + ), + ...world.ruleAuras().flatMap((aura) => players.map((owner) => ({ owner, aura }))), + ]); +} + +/** + * How many times a trigger listening for `on`, on a minion `owner` controls, activates + * when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in + * force the answer is 1. Multipliers with `stacking: "max"` contribute their largest + * `times`; each `"additive"` one adds `times - 1` extra activations. A cap + * (`triggerCap`) then limits the total to its `times`. + */ +export function activations(world: World, owner: PlayerId, on: TriggerOn): number { + let max = 1; + let extra = 0; + let cap = Infinity; + for (const { owner: auraOwner, aura } of world.activeAuras()) { + if (auraOwner !== owner || aura.trigger !== on) continue; + if (aura.type === "triggerCap") cap = Math.min(cap, aura.times); + else if (aura.stacking === "max") max = Math.max(max, aura.times); + else extra += aura.times - 1; + } + return Math.min(cap, max + extra); +} diff --git a/course/10-spellgrid/reference/ts/src/engine/card.ts b/course/10-spellgrid/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..f12bd61 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/card.ts @@ -0,0 +1,182 @@ +// Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`), +// read straight from JSON. The engine compiles each definition once, when it is +// registered, into the lists it reads while resolving: triggers, auras and a +// spell's effects. The definitions themselves live in `cards/`, loaded by `content`. + +/** What a triggered ability listens for. */ +export type TriggerOn = + | "selfDamaged" // this minion takes damage + | "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage + | "anyMinionDamaged" // any minion takes damage + | "heroDamaged" // either hero takes damage + | "deathrattle" // this minion dies (it resolves after the minion has left play) + | "battlecry" // this minion is played from hand + | "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires + +/** The trigger's family name, as reported in `Triggered` events. */ +export function triggerLabel(on: TriggerOn): string { + switch (on) { + case "selfDamaged": + case "friendlyMinionDamaged": + case "anyMinionDamaged": + case "heroDamaged": + return "damaged"; + case "deathrattle": + case "battlecry": + case "endOfTurn": + return on; + } +} + +/** + * An aura: a continuous effect a minion has while it is in play. The engine reads + * auras only at the Aura Update points (see `aura.ts`), never mid-Phase. + * + * `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with + * the aura owner's owner, activate `times` times each. `stacking` says how several + * multipliers for the same trigger combine: `"max"` takes the largest (two copies of + * a doubler still double); `"additive"` adds each copy's extra activations. + */ +export type Aura = + | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" } + /** + * Module 10: triggers listening for `trigger` activate at most `times` times, whatever + * multiplies them. Not in the card format: SpellGrid compiles it from a negated rule + * (`DEATHRATTLE IS NOT DOUBLE`). + */ + | { type: "triggerCap"; trigger: TriggerOn; times: number }; + +/** Who an effect touches. */ +export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen"; + +/** Which minions an Assemble may pick as its base. An empty filter allows any. */ +export interface PoolFilter { + /** Only minions with a trigger clause listening for this. */ + trigger?: TriggerOn; +} + +/** One effect: a node of a card's ability, interpreted by `effect.ts`. */ +export type Effect = + | { type: "dealDamage"; target: Target; amount: number } + | { type: "draw"; count: number } + | { type: "summon"; card: string; count: number } + | { type: "gainArmor"; amount: number } + | { type: "gainAttack"; target: Target; amount: number } + | { type: "destroy"; target: Target } + /** Part III (SpellGrid) only: puts a word on the grid. */ + | { type: "spawnText"; word: string; at: "self" } + /** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */ + | { type: "assemble"; filter: PoolFilter } + /** Give every minion in the owner's hand +attack/+health. */ + | { type: "buffHand"; attack: number; health: number }; + +/** + * One line of card text. A trigger clause applies to the effect clauses after it, + * until the next trigger, keyword or aura. Effects before any trigger are what a + * spell does when cast. + */ +export type Clause = + | { kind: "keyword"; keyword: string } + | { kind: "trigger"; on: TriggerOn } + | { kind: "aura"; modifier: Aura } + | { kind: "effect"; effect: Effect }; + +/** A card as data, in the course's card format. */ +export interface CardDef { + name: string; + type: "minion" | "spell"; + cost: number; + attack?: number; + health?: number; + noun?: string; + text: Clause[]; + /** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */ + split: number; + fictional?: boolean; + game?: string; +} + +/** A triggered ability: "whenever , ". */ +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/10-spellgrid/reference/ts/src/engine/death.ts b/course/10-spellgrid/reference/ts/src/engine/death.ts new file mode 100644 index 0000000..6dc715d --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/ts/src/engine/effect.ts b/course/10-spellgrid/reference/ts/src/engine/effect.ts new file mode 100644 index 0000000..bb98be5 --- /dev/null +++ b/course/10-spellgrid/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": + return [{ type: "spawnText", source: ctx.minion(), word: effect.word }]; + } +} + +/** The entities a target names, from where the effect runs. */ +function targets(target: Target, ctx: Ctx): EntityId[] { + const { world } = ctx; + const others = (ids: EntityId[]) => ids.filter((id) => id !== ctx.source); + switch (target) { + case "self": + return [ctx.minion()]; + case "allMinions": + return world.minionsInPlay(); + case "allOtherMinions": + return others(world.minionsInPlay()); + case "allOtherCharacters": + return others([world.hero(0), world.hero(1), ...world.minionsInPlay()]); + case "enemyHero": + return [ctx.enemyHero()]; + case "chosen": + return ctx.target === undefined ? [] : [ctx.target]; + } +} + +/** + * Where a summon goes: to the right of a source still on the board; where a dead + * source stood (its Death Event's position); at the right end for a spell. + */ +function summonPosition(ctx: Ctx): number | undefined { + const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source); + return index === undefined ? ctx.position : index + 1; +} diff --git a/course/10-spellgrid/reference/ts/src/engine/event.ts b/course/10-spellgrid/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..fffe1e1 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/event.ts @@ -0,0 +1,27 @@ +// What happened, in order. The engine records an event each time it changes the game. + +import type { TriggerOn } from "./card"; +import type { EntityId, PlayerId } from "./world"; + +export type Event = + | { type: "Summoned"; id: EntityId } + | { type: "Damaged"; target: EntityId; amount: number } + | { type: "Triggered"; source: EntityId; on: TriggerOn } + | { type: "Drew"; player: PlayerId; id: EntityId } + | { type: "Fatigue"; player: PlayerId } + | { type: "ArmorGained"; player: PlayerId; amount: number } + | { type: "StatsChanged"; id: EntityId; attack: number; health: number } + | { type: "SpellCast"; card: string } + /** + * A Death Event: the Death Creation Step moved `id` from play to the graveyard. + * `position` is the board index it had when the step began; its Deathrattle summons there. + */ + | { type: "Destroyed"; id: EntityId; position: number } + | { type: "DeathPhase"; round: number } + /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ + | { type: "Played"; id: EntityId } + /** + * Module 10: `source`'s effect asked for `word` to be written on its grid tile. Part I + * has no grid: it only records the request. SpellGrid reads it when the Sequence ends. + */ + | { type: "TextSpawned"; source: EntityId; word: string }; diff --git a/course/10-spellgrid/reference/ts/src/engine/index.ts b/course/10-spellgrid/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..d052a0f --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/index.ts @@ -0,0 +1,53 @@ +// Engine core: generic rules machinery. Never name a specific card here. +// +// Module 1: every card in the game is an entity addressed by an id handle. +// The engine knows which zone each entity is in, who owns it, and when it +// entered play. +// +// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the +// action queue that resolves a Sequence depth-first, recording an event log. +// +// Module 3: deferred death. Damage and destroy effects only mark minions; the +// Death Creation Step removes them when the outermost Phase ends, and Death +// Phases resolve their Deathrattles until no new deaths occur. +// +// Module 4: auras as card data, Aura Updates at defined points, and one +// routine that decides how many times a queued trigger activates. Playing a +// minion from hand, and Battlecries. +// +// Module 5: behaviour as data. Cards are definitions in the course's card format, +// loaded from JSON; one interpreter turns their effects into actions. Assemble +// builds a new card by swapping half of one card's text for half of another's. +// +// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game, +// beside the first. +// Module 6: the grid, the lexer and the parser. +// Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force. +// Module 8: movement, planned in full and then committed; push chains. +// Module 9: the turn, as a list of phases; hazards; winning. +// +// Part III lives in `spellgrid.ts`, the only file that knows both engines. +// Module 10: SpellGrid. Part I gains auras from outside the cards (`World.setRuleAuras`), +// a cap on activations, the `spawnText` effect (recorded as a `TextSpawned` event) and +// the end-of-Phase steps as a list (`END_OF_PHASE`); Part II gains a `Destroy` hook. + +export * as puzzle from "./puzzle"; +export { AssembleError, assemble, registerAssembled, type Half } from "./assemble"; +export { + CardLibrary, + triggerLabel, + type Aura, + type Card, + type CardDef, + type Clause, + type Effect, + type PoolFilter, + type Target, + type Trigger, + type TriggerOn, +} from "./card"; +export type { Event } from "./event"; +export { auraUpdateOther } from "./aura"; +export { Ctx, END_OF_PHASE, runSequence, summon, type Action, type EndOfPhaseStep } from "./queue"; +export { ruleAuras, SpellGrid, type SeamWords } from "./spellgrid"; +export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world"; diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts new file mode 100644 index 0000000..9cfa771 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts @@ -0,0 +1,127 @@ +// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of +// objects may share a tile. + +export type ObjectId = number; + +/** A level in the course's level format (`course/schema/level.schema.json`). */ +export interface LevelDef { + width: number; + height: number; + objects: LevelObject[]; + /** Module 10, read only by SpellGrid: minions in play, each on its tile. */ + cards?: LevelCard[]; + /** Module 10, read only by SpellGrid: the cards in player 0's hand. */ + hand?: string[]; +} + +/** A minion card on the grid (Module 10). */ +export interface LevelCard { + card: string; + x: number; + y: number; +} + +export interface LevelObject { + kind: string; + x: number; + y: number; +} + +/** + * What an object is: a thing named by a noun (kind `""`, 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() }; + }, + /** The kind as it is placed: `""` in lower case, or `"text:"`. */ + format(kind: Kind): string { + return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase(); + }, +}; + +export interface Obj { + id: ObjectId; + kind: Kind; + x: number; + y: number; +} + +export class Board { + private readonly objectsById = new Map(); + private nextId = 0; + + constructor( + readonly width: number, + readonly height: number, + ) {} + + inBounds(x: number, y: number): boolean { + return x >= 0 && x < this.width && y >= 0 && y < this.height; + } + + /** Add an object; ids start at 1 and are never reused. Throws off the board. */ + place(kind: Kind, x: number, y: number): ObjectId { + if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`); + const id = ++this.nextId; + this.objectsById.set(id, { id, kind, x, y }); + return id; + } + + /** + * An id that no object will ever have (Module 10): SpellGrid gives one to each + * card that isn't on the grid, so cards and objects share one id space. + */ + reserveId(): ObjectId { + return ++this.nextId; + } + + /** The object `id`. Throws if there is none. */ + get(id: ObjectId): Obj { + const o = this.objectsById.get(id); + if (o === undefined) throw new Error(`no object ${id}`); + return o; + } + + /** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */ + find(id: ObjectId): Obj | undefined { + if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`); + return this.objectsById.get(id); + } + + /** Take object `id` off the board for good. */ + remove(id: ObjectId): void { + this.objectsById.delete(id); + } + + /** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */ + shift(id: ObjectId, [dx, dy]: readonly [number, number]): void { + const o = this.get(id); + const [x, y] = [o.x + dx, o.y + dy]; + if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`); + o.x = x; + o.y = y; + } + + /** Make object `id` a `kind`, where it stands. Throws if there is no such object. */ + setKind(id: ObjectId, kind: Kind): void { + this.get(id).kind = kind; + } + + /** The objects on tile (x, y), oldest first. */ + at(x: number, y: number): Obj[] { + return this.objects().filter((o) => o.x === x && o.y === y); + } + + /** Every object, oldest first. */ + objects(): Obj[] { + return [...this.objectsById.values()]; + } +} diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/index.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/index.ts new file mode 100644 index 0000000..64d1033 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/index.ts @@ -0,0 +1,31 @@ +// Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is +// compiled from the text on the board, and recompiled whenever the board changes. +// +// Module 6: the grid, the lexer (text objects to words with grammatical roles) +// and the parser (words to rules). The engine knows the operators `IS`, `AND`, +// `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word, +// nouns included, is data handed in by content. +// +// Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the +// identity lock) that turns the parsed rules into the rules in force. Objects are +// asked about through `Level.hasProperty`, which knows which nouns name them. +// +// Module 8: movement. `Level.moveYou` plans each `YOU` object's move in full +// (`Planner`), then commits it, then reparses. What a property does when something +// moves is a `Response` that content provides; the engine runs them in order. +// +// Module 9: the turn. `Level.step` runs the phases of `TURN` in order: movement, +// parse, conversion, parse, hazards, win, parse. What a hazard property destroys +// is a `Hazard` that content provides; the engine knows `YOU`, `WIN` and `FLOAT`. +// +// Module 10: `Level.step` and `Level.moveYou` take a `Destroy`, which decides what +// happens to the objects they destroy; `Board.reserveId` hands out an id no object has. +// The puzzle engine still knows nothing of cards: SpellGrid (`../spellgrid.ts`) uses these. + +export { Board, Kind, type LevelCard, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid"; +export { FLOAT, Level, removeAll, WIN, YOU, type Destroy } from "./level"; +export { delta, Planner, type Dir, type MoveContext, type Plan, type Pos, type Response, type Step } from "./movement"; +export { BASE_RULES, isConversion, isIdentity, negates, parse, ruleText, type Rule } from "./parse"; +export { resolve } from "./resolve"; +export { TURN, type Hazard, type Phase } from "./turn"; +export { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word"; diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts new file mode 100644 index 0000000..520e13c --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts @@ -0,0 +1,248 @@ +// A level in play: the board, the words content provides, and the rules compiled +// from them. Every change to the board recompiles the rules from scratch: parse, +// then resolve. Every question about what an object is goes through `hasProperty`. +// +// A move (Module 8) is planned for each `YOU` object, then committed; the rules are +// reparsed once, after all of them. +// +// A turn (Module 9) runs the phases of `TURN` in order: movement, parse, +// conversion, parse, content's hazards, the win check, parse. +// +// Module 10: what happens to the objects a turn destroys is a `Destroy` the caller +// may pass in. The default removes them. SpellGrid passes one that sends minions to +// Part I's Death Creation Step instead. + +import { Board, Kind, type LevelDef, type ObjectId } from "./grid"; +import { delta, Planner, type Dir, type Plan, type Response } from "./movement"; +import { isConversion, isIdentity, parse, type Rule } from "./parse"; +import { resolve } from "./resolve"; +import { TURN, type Hazard } from "./turn"; +import { Lexicon, type WordDef } from "./word"; + +/** The property that makes an object move when the player moves. */ +export const YOU = "YOU"; +/** The property that wins the level when a `YOU` object is on it, or is it. */ +export const WIN = "WIN"; +/** + * The property that puts an object on the other float level: only objects on + * the same level share a tile for hazards and for winning. + */ +export const FLOAT = "FLOAT"; + +/** + * What to do with objects a move or a turn destroys (Module 10). It may change the + * board, and reparse, before it returns. An id may appear more than once. + */ +export type Destroy = (level: Level, ids: ObjectId[]) => void; + +/** The default `Destroy`: take every object off the board. */ +export const removeAll: Destroy = (level, ids) => { + for (const id of ids) level.board.remove(id); +}; + +export class Level { + readonly board: Board; + private readonly lexicon: Lexicon; + private compiled: Rule[] = []; + private active: Rule[] = []; + private hasWon = false; + + /** + * A level from its definition, the words content provides, and content's + * movement responses and hazards, each in the order they are to run. + */ + constructor( + def: LevelDef, + words: readonly WordDef[], + private readonly moveResponses: readonly Response[], + private readonly hazards: readonly Hazard[], + ) { + this.board = new Board(def.width, def.height); + this.lexicon = new Lexicon(words); + for (const o of def.objects) this.board.place(Kind.parse(o.kind), o.x, o.y); + this.reparse(); + } + + place(kind: string, x: number, y: number): ObjectId { + const id = this.board.place(Kind.parse(kind), x, y); + this.reparse(); + return id; + } + + /** The movement responses, in the order they run. */ + responses(): readonly Response[] { + return this.moveResponses; + } + + /** Every rule the text spells, as parsed: negated rules and duplicates included. */ + rules(): readonly Rule[] { + return this.compiled; + } + + /** The rules in force after the resolution pass. */ + activeRules(): readonly Rule[] { + return this.active; + } + + /** + * Does object `id` have `property`? Yes if an active rule gives it to a noun + * that names the object and no active negated rule takes it from one. A + * destroyed object has no properties. + */ + hasProperty(id: ObjectId, property: string): boolean { + const object = this.board.find(id); + if (object === undefined) return false; + const kind = object.kind; + const aboutIt = this.active.filter((r) => r.complement === property && this.lexicon.names(r.subject, kind)); + return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated); + } + + /** + * The other objects on object `id`'s tile that are on its float level (both + * `FLOAT` or both not), oldest first. Hazards and the win check ask this. + */ + sharing(id: ObjectId): ObjectId[] { + const o = this.board.get(id); + const floats = this.hasProperty(id, FLOAT); + return this.board + .at(o.x, o.y) + .filter((other) => other.id !== id && this.hasProperty(other.id, FLOAT) === floats) + .map((other) => other.id); + } + + /** Has a turn's win check found a `YOU` object on a `WIN` one? Stays true. */ + won(): boolean { + return this.hasWon; + } + + /** Is there no `YOU` object left? */ + lost(): boolean { + return !this.board.objects().some((o) => this.hasProperty(o.id, YOU)); + } + + /** + * Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement + * only: no conversion, hazard or win. + */ + moveYou(dir: Dir, destroy: Destroy = removeAll): void { + this.movement(dir, destroy); + this.reparse(); + } + + /** Play one turn: the phases of `TURN`, in order. Destroyed objects go to `destroy`. */ + step(dir: Dir, destroy: Destroy = removeAll): void { + for (const phase of TURN) { + switch (phase) { + case "movement": + this.movement(dir, destroy); + break; + case "parse": + this.reparse(); + break; + case "conversion": + this.convert(destroy); + break; + case "hazards": + this.runHazards(destroy); + break; + case "win": + this.checkWin(); + break; + } + } + } + + /** + * The `YOU` objects move one at a time, the one furthest ahead in `dir` first + * (oldest first on a tie); each one's move is planned in full, then committed, + * before the next is planned. Every move uses the rules in force before the first. + */ + private movement(dir: Dir, destroy: Destroy): void { + const [dx, dy] = delta(dir); + const ahead = (o: { x: number; y: number }) => o.x * dx + o.y * dy; + const you = this.board + .objects() + .filter((o) => this.hasProperty(o.id, YOU)) + .sort((a, b) => ahead(b) - ahead(a) || a.id - b.id) + .map((o) => o.id); + for (const id of you) { + if (this.board.find(id) === undefined) continue; // destroyed by an earlier move + const planner = new Planner(this, dir); + if (planner.tryMove(id)) this.commit(planner.result(), dir, destroy); + } + } + + /** Destroy, then move what is still on the board (a `Destroy` may remove more than it was given). */ + private commit(plan: Plan, dir: Dir, destroy: Destroy): void { + if (plan.destroyed.size > 0) destroy(this, [...plan.destroyed]); + for (const id of plan.moving) { + if (!plan.destroyed.has(id) && this.board.find(id) !== undefined) this.board.shift(id, delta(dir)); + } + } + + /** + * Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y` where + * it stands, keeping its id; with several `Y`s it becomes the first, and each + * other `Y` is a new object on its tile. An `X IS X` naming the object blocks + * this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s, + * as new objects: the negation cancelled `X IS X`. + * + * Every object is decided from the board as movement left it, before any + * changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. + */ + private convert(destroy: Destroy): void { + const changes: { id: ObjectId; x: number; y: number; deleted: boolean; into: Kind[] }[] = []; + for (const o of this.board.objects()) { + const aboutIt = this.active.filter((r) => this.lexicon.names(r.subject, o.kind)); + const deleted = aboutIt.some((r) => r.negated && isIdentity(r)); + const locked = aboutIt.some((r) => !r.negated && isIdentity(r)); + const into: Kind[] = []; + if (!locked) { + for (const r of aboutIt) { + if (r.negated || !isConversion(r)) continue; + const kind = this.lexicon.thingNamed(r.complement); + if (kind === undefined) continue; + const same = (k: Kind) => Kind.format(k) === Kind.format(kind); + if (!same(o.kind) && !into.some(same)) into.push(kind); + } + } + if (deleted || into.length > 0) changes.push({ id: o.id, x: o.x, y: o.y, deleted, into }); + } + for (const { id, x, y, deleted, into } of changes) { + if (!deleted) this.board.setKind(id, into[0]); + for (const kind of deleted ? into : into.slice(1)) this.board.place(kind, x, y); + } + const deleted = changes.filter((c) => c.deleted).map((c) => c.id); + if (deleted.length > 0) destroy(this, deleted); + } + + /** + * Run content's hazards in order. Each one's victims are destroyed together + * before the next runs; no reparse until the turn's last phase. + */ + private runHazards(destroy: Destroy): void { + for (const hazard of this.hazards) { + const victims = hazard(this); + if (victims.length > 0) destroy(this, victims); + } + } + + /** + * The level is won if any `YOU` object is `WIN`, or shares its tile with a + * `WIN` object on its float level. + */ + private checkWin(): void { + const wins = (id: ObjectId) => this.hasProperty(id, WIN); + const won = this.board + .objects() + .filter((o) => this.hasProperty(o.id, YOU)) + .some((o) => wins(o.id) || this.sharing(o.id).some(wins)); + this.hasWon ||= won; + } + + /** Recompile the rules from the board: parse, then resolve. */ + reparse(): void { + this.compiled = parse(this.board, this.lexicon); + this.active = resolve(this.compiled); + } +} diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts new file mode 100644 index 0000000..36fc15b --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts @@ -0,0 +1,121 @@ +// Movement: validate, then commit. +// +// A move is planned before anything on the board changes. The `Planner` asks +// whether one object can step one tile; to answer, it runs every movement +// response content provides for that step, and a response may ask the planner +// to move other objects too (a push), or to destroy objects. If any response +// says no, the planner forgets everything that step added to the plan, so a +// failed push leaves no trace. Only a plan that succeeded is committed. +// +// The engine knows the board, its edge, and the order of the steps. What each +// property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a +// response in content, run in the order content lists them. + +import type { Board, ObjectId } from "./grid"; + +/** One of the four directions a move can take. */ +export type Dir = "up" | "down" | "left" | "right"; + +export type Pos = readonly [number, number]; + +/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */ +export function delta(dir: Dir): Pos { + switch (dir) { + case "up": + return [0, -1]; + case "down": + return [0, 1]; + case "left": + return [-1, 0]; + case "right": + return [1, 0]; + } +} + +/** One object stepping one tile, from `from` to `to`, in direction `dir`. */ +export interface Step { + mover: ObjectId; + from: Pos; + to: Pos; + dir: Dir; +} + +/** + * A movement response: content's rule for what happens when an object steps. + * It returns `false` to block the step. It may plan other moves or destroy + * objects through the planner. + */ +export type Response = (p: Planner, s: Step) => boolean; + +/** + * What a move will do: the objects that step one tile, and the objects destroyed. + * A destroyed object doesn't step, even if it was pushed. + */ +export interface Plan { + moving: Set; + destroyed: Set; +} + +/** What the planner reads: the board, the properties in force, the responses. */ +export interface MoveContext { + readonly board: Board; + hasProperty(id: ObjectId, property: string): boolean; + responses(): readonly Response[]; +} + +/** Plans one move on a board it only reads. */ +export class Planner { + private plan: Plan = { moving: new Set(), destroyed: new Set() }; + + constructor( + private readonly level: MoveContext, + private readonly dir: Dir, + ) {} + + /** Does object `id` have `property`, under the rules in force before the move? */ + has(id: ObjectId, property: string): boolean { + return this.level.hasProperty(id, property); + } + + /** + * The objects that will still be on tile `at` when the plan so far is + * committed: those there now, less those planned to move away or be destroyed. + * Oldest first. + */ + stayingAt([x, y]: Pos): ObjectId[] { + return this.level.board + .at(x, y) + .map((o) => o.id) + .filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id)); + } + + /** Plan object `id`'s destruction. */ + destroy(id: ObjectId): void { + this.plan.destroyed.add(id); + } + + /** + * Plan object `id` stepping one tile in the move's direction, with whatever + * the responses add. Returns whether it can. If it can't, the plan is left as + * it was before the call. An object already planned to move, or to be + * destroyed, is out of the way: the answer is yes, and nothing is added. + */ + tryMove(id: ObjectId): boolean { + if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; + const o = this.level.board.get(id); + const [dx, dy] = delta(this.dir); + const to: Pos = [o.x + dx, o.y + dy]; + // The edge of the level blocks every step (the course's choice; see the lesson). + if (!this.level.board.inBounds(...to)) return false; + const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) }; + this.plan.moving.add(id); + const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir }; + const allowed = this.level.responses().every((respond) => respond(this, step)); + if (!allowed) this.plan = before; + return allowed; + } + + result(): Plan { + return this.plan; + } +} diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/parse.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/parse.ts new file mode 100644 index 0000000..e506244 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/parse.ts @@ -0,0 +1,130 @@ +// The parser: words on the grid become rules. +// +// Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`, +// read left to right in a row and top to bottom in a column. The parser starts at +// every `IS`, collects the subjects before it and the complements after it, and +// emits one rule per pair: AND distributes over both sides. A tile holding several +// words contributes each of them, so stacked text yields every possible rule. +// +// `NOT` before a complement negates that one complement. `NOT` before a subject is +// not in the grammar: the run of subjects stops there and that subject is dropped. +// +// The parser only reads; which rules win is the resolution pass (`resolve`). + +import type { Board, ObjectId } from "./grid"; +import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word"; + +/** + * One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`. + * `text` is the text objects that spell it (subject, `IS`, `NOT` if any, + * complement), or empty for a base rule. + */ +export interface Rule { + subject: string; + complement: string; + complementRole: Role; + negated: boolean; + text: ObjectId[]; +} + +/** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`. */ +export const ruleText = (r: Rule): string => `${r.subject} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`; + +/** `X IS Y` where `Y` is a noun: every X is to become a Y. */ +export const isConversion = (r: Rule): boolean => r.complementRole === "noun"; + +/** `X IS X` (or `X IS NOT X`). */ +export const isIdentity = (r: Rule): boolean => r.subject === r.complement; + +/** Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. */ +export const negates = (n: Rule, r: Rule): boolean => + n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement; + +/** Rules in force with no text: the game's base rules (`[subject, property]`). */ +export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]]; + +type Tiles = Map; +type Pos = readonly [number, number]; + +/** A word in a run, with the `NOT` in front of it if there is one. */ +interface Word { + not?: Token; + token: Token; +} + +/** + * Every rule the board spells, base rules first, negated rules included. A rule + * spelled by two different sets of text objects appears twice. + */ +export function parse(board: Board, lexicon: Lexicon): Rule[] { + const tiles = lex(board, lexicon); + const rules: Rule[] = BASE_RULES.map(([subject, property]) => ({ + subject, + complement: property, + complementRole: "property", + negated: false, + text: [], + })); + for (const o of board.objects()) { + if (o.kind.type !== "text" || o.kind.word !== IS) continue; + for (const [dx, dy] of [ + [1, 0], + [0, 1], + ] as const) { + const ss = subjects(tiles, [o.x - dx, o.y - dy], [-dx, -dy]); + const cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]); + for (const s of ss) { + for (const c of cs) { + rules.push({ + subject: s.word, + complement: c.token.word, + complementRole: c.token.role, + negated: c.not !== undefined, + text: c.not ? [s.id, o.id, c.not.id, c.token.id] : [s.id, o.id, c.token.id], + }); + } + } + } + } + return rules; +} + +const wordsAt = (tiles: Tiles, [x, y]: Pos): Token[] => tiles.get(tileKey(x, y)) ?? []; +const holds = (tiles: Tiles, p: Pos, word: string): boolean => wordsAt(tiles, p).some((t) => t.word === word); +const ahead = ([x, y]: Pos, [dx, dy]: Pos): Pos => [x + dx, y + dy]; + +/** + * The run of subjects before an `IS`, walking backwards from `at`: the nouns at + * `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT` + * before a noun ends the run without it (a negated subject is not in the grammar). + */ +function subjects(tiles: Tiles, at: Pos, step: Pos): Token[] { + const before = ahead(at, step); + if (holds(tiles, before, NOT)) return []; + const found = wordsAt(tiles, at).filter((t) => t.role === "noun"); + if (found.length > 0 && holds(tiles, before, AND)) found.push(...subjects(tiles, ahead(before, step), step)); + return found; +} + +/** + * The run of complements after an `IS`, walking forwards from `at`: the nouns + * and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it, + * negated; then, if the next tile holds an AND, the run beyond it. + */ +function complements(tiles: Tiles, at: Pos, step: Pos): Word[] { + const fits = (t: Token) => t.role === "noun" || t.role === "property"; + const found: Word[] = []; + // Plain words here, and negated words one tile on: a stack may hold both. + const branches: [Token | undefined, Pos][] = [[undefined, at]]; + for (const not of wordsAt(tiles, at).filter((t) => t.word === NOT)) branches.push([not, ahead(at, step)]); + for (const [not, p] of branches) { + const words = wordsAt(tiles, p) + .filter(fits) + .map((token) => ({ not, token })); + if (words.length === 0) continue; + found.push(...words); + const andAt = ahead(p, step); + if (holds(tiles, andAt, AND)) found.push(...complements(tiles, ahead(andAt, step), step)); + } + return found; +} diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/resolve.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/resolve.ts new file mode 100644 index 0000000..5333afa --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/resolve.ts @@ -0,0 +1,23 @@ +// The resolution pass: from every rule the text spells to the rules in force. +// +// The parser reports what is written; this pass decides what wins, over the whole +// rule list at once, so it doesn't matter where on the board each rule is written: +// +// 1. Negation. `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in +// force: it keeps overriding, and `X IS NOT X` is also the record that every X is +// to vanish (the turn removes them, Module 9). +// 2. Identity. A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun): +// X can't become anything else. It doesn't stop other nouns becoming X. Because +// negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again. +// +// Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`. + +import { isConversion, isIdentity, negates, type Rule } from "./parse"; + +/** The rules in force, in the order they were parsed. */ +export function resolve(rules: readonly Rule[]): Rule[] { + const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r))); + const locked = (subject: string) => + survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject); + return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject)); +} diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/turn.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/turn.ts new file mode 100644 index 0000000..4742618 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/turn.ts @@ -0,0 +1,33 @@ +// The turn (Module 9): one move of the player, as a fixed list of phases. +// +// The course's turn is a labelled simplification of the game's (see the lesson): +// movement, parse, conversion, parse, hazards, win, and a last parse so that +// the rules match the board when the turn ends. Each phase reads the rules from +// the parse before it, so a rule that movement breaks is gone before hazards run. +// +// What a hazard property does (`SINK`, `HOT`/`MELT`, `DEFEAT`, ...) is a `Hazard` +// that content provides, as with movement's responses. The engine runs them in +// content's order and owns only the phases, `YOU`, `WIN` and float levels. + +import type { ObjectId } from "./grid"; +import type { Level } from "./level"; + +/** + * One phase of the turn: + * - `"movement"`: every `YOU` object moves (Module 8), without the reparse; + * - `"parse"`: the rules are recompiled from the board; + * - `"conversion"`: `X IS Y` conversions, and `X IS NOT X` deletions; + * - `"hazards"`: content's hazards, in content's order; + * - `"win"`: a `YOU` object on a `WIN` one (or `WIN` itself) wins the level. + */ +export type Phase = "movement" | "parse" | "conversion" | "hazards" | "win"; + +/** The turn, in order. */ +export const TURN: readonly Phase[] = ["movement", "parse", "conversion", "parse", "hazards", "win", "parse"]; + +/** + * A hazard: content's rule for what destroys what at the end of a turn. It reads + * the level and returns the objects to destroy; the engine destroys them all at + * once, before the next hazard runs. An id may appear more than once. + */ +export type Hazard = (level: Level) => ObjectId[]; diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/word.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/word.ts new file mode 100644 index 0000000..cd5c80f --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/word.ts @@ -0,0 +1,106 @@ +// The lexer: text objects become words with a grammatical role. +// +// The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`. +// Every other word comes from content as data: the engine never names a noun. +// A noun also says which objects it names (`Selects`), so a meta-noun that names +// a whole group of objects can be data too. + +import type { Board, Kind, ObjectId } from "./grid"; + +export type Role = "noun" | "property" | "operator"; + +/** + * Which objects a noun names when it is the subject of a rule: + * - `"named"` (the default): the things of its own name, `` names every object of kind `""`; + * - `"text"`: every text object (the meta-noun `TEXT`); + * - `"things"`: every object that is not text, whatever its noun. + */ +export type Selects = "named" | "text" | "things"; + +/** + * One entry of the word list content provides: `{ "name": "", "role": "noun" }`, + * optionally with `"selects"` (default `"named"`). + */ +export interface WordDef { + name: string; + role: Role; + selects?: Selects; +} + +export const IS = "IS"; +export const AND = "AND"; +export const NOT = "NOT"; +/** The meta-noun for every text object. */ +export const TEXT = "TEXT"; + +/** Every word the parser can read, with its role. */ +export class Lexicon { + private readonly roles = new Map(); + private readonly selects = 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); + this.selects.set(w.name.toUpperCase(), w.selects ?? "named"); + } + for (const op of [IS, AND, NOT]) this.roles.set(op, "operator"); + this.roles.set(TEXT, "noun"); + this.selects.set(TEXT, "text"); + } + + /** `undefined` for a word the lexicon doesn't know: such text spells nothing. */ + role(word: string): Role | undefined { + return this.roles.get(word); + } + + /** Does the noun `word`, as the subject of a rule, name an object of this kind? */ + names(word: string, kind: Kind): boolean { + switch (this.selects.get(word) ?? "named") { + case "named": + return kind.type === "thing" && kind.noun === word; + case "text": + return kind.type === "text"; + case "things": + return kind.type === "thing"; + } + } + + /** + * What a conversion into the noun `word` makes (Module 9): a thing of that + * name, if `word` names the things of its own name. `undefined` for a noun that + * names a group (`TEXT`, or `ALL` if content adds it): the course doesn't + * convert into those. + */ + thingNamed(word: string): Kind | undefined { + return (this.selects.get(word) ?? "named") === "named" ? { type: "thing", noun: word } : undefined; + } +} + +/** A piece of text the parser can read: which object, which word, which role. */ +export interface Token { + id: ObjectId; + word: string; + role: Role; +} + +/** Tile id for maps: `"x,y"`. */ +export const tileKey = (x: number, y: number): string => `${x},${y}`; + +/** + * The words on the board, by tile; a tile's words in object order (oldest first). + * Text whose word the lexicon doesn't know is left out. + */ +export function lex(board: Board, lexicon: Lexicon): Map { + 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/10-spellgrid/reference/ts/src/engine/queue.ts b/course/10-spellgrid/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..3558522 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/queue.ts @@ -0,0 +1,258 @@ +// The action queue: how one player action (a Sequence) resolves. +// +// Performing an action changes the world and produces events. Every trigger that +// responds to those events goes into one Queue, oldest listener first, and the Queue +// is locked. Each queued trigger then resolves completely, including everything +// its own actions cause, before the next one starts: depth-first. +// +// Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the +// Death Creation Step removes the dead, and a Death Phase resolves their +// Deathrattles. That repeats until a step finds no new deaths. +// +// Module 4: a queued trigger may activate more than once. How many times is +// decided by one routine, `activations` in `aura.ts`, from aura data on cards. +// Nothing here knows which cards multiply which triggers. +// +// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and +// its actions performed before the next effect of the same ability is read, so +// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion. +// +// Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`, +// read from the world, as Part II's turn is a list of phases. SpellGrid's tests run +// the same Sequence with the list reversed, to show what the order decides. + +import { assembleFromPool } from "./assemble"; +import { activations, auraUpdateOther } from "./aura"; +import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card"; +import { deathCreationStep } from "./death"; +import { actions } from "./effect"; +import type { Event } from "./event"; +import type { EntityId, PlayerId, World } from "./world"; + +/** Something the engine can do. Card effects return these instead of changing the world. */ +export type Action = + /** Deal `amount` to each target at once: all damage first, then the reactions. */ + | { type: "damage"; targets: EntityId[]; amount: number } + | { type: "draw"; player: PlayerId } + | { type: "gainArmor"; player: PlayerId; amount: number } + | { type: "gainAttack"; id: EntityId; amount: number } + | { type: "cast"; card: string; player: PlayerId; target?: EntityId } + /** Mark each target pending destroy. They leave play in the next Death Creation Step. */ + | { type: "destroy"; targets: EntityId[] } + /** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */ + | { type: "summon"; card: string; player: PlayerId; at?: number } + /** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */ + | { type: "play"; id: EntityId } + /** Put an Assembled minion into `player`'s hand (see `assemble.ts`). */ + | { type: "assemble"; player: PlayerId; filter: PoolFilter } + /** Give one entity +attack/+health. */ + | { type: "buff"; id: EntityId; attack: number; health: number } + /** Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it. */ + | { type: "spawnText"; source: EntityId; word: string }; + +/** + * A step that runs when the outermost Phase ends: + * - `"deathCreation"`: the Death Creation Step (Module 3); + * - `"auraUpdate"`: an Aura Update (Other) (Module 4). + */ +export type EndOfPhaseStep = "deathCreation" | "auraUpdate"; + +/** + * The end of the outermost Phase, in Hearthstone's order: the dead leave play first, + * then the auras are read again, so a minion that just died multiplies nothing in + * the Death Phase that follows. + */ +export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"]; + +/** What an effect may look at when it runs. */ +export class Ctx { + constructor( + readonly world: World, + /** The player the effect works for. */ + readonly owner: PlayerId, + /** The minion whose trigger is resolving; undefined for a spell. */ + readonly source?: EntityId, + /** The chosen target, if any. */ + readonly target?: EntityId, + /** + * The source's board position. For a Deathrattle, the source has left play, + * and this is the position its Death Event remembered. + */ + readonly position?: number, + ) {} + + /** The minion whose trigger is resolving. */ + minion(): EntityId { + if (this.source === undefined) throw new Error("only a triggered ability has a source"); + return this.source; + } + + enemyHero(): EntityId { + return this.world.hero(this.owner === 0 ? 1 : 0); + } +} + +/** Run one Sequence: a player action and everything it causes. */ +export function runSequence(world: World, action: Action): void { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let deaths = endOfOutermostPhase(world); + for (let round = 1; deaths.length > 0; round++) { + world.record({ type: "DeathPhase", round }); + react(world, deaths); + deaths = endOfOutermostPhase(world); + } +} + +/** + * The steps that run only when the outermost Phase ends, in the world's order + * (`END_OF_PHASE` unless changed); returns the Death Events. Hearthstone runs an + * Aura Update (Health/Attack) first. The course has no Health/Attack auras, so that + * step has nothing to do and is left out. + */ +function endOfOutermostPhase(world: World): Event[] { + let deaths: Event[] = []; + for (const step of world.endOfPhase()) { + switch (step) { + case "deathCreation": + deaths = deathCreationStep(world); + break; + case "auraUpdate": + auraUpdateOther(world); + break; + } + } + return deaths; +} + +/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */ +export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId { + const id = world.create(card, player, "board", at); + auraUpdateOther(world); + return id; +} + +function perform(world: World, action: Action): void { + switch (action.type) { + case "damage": { + // Area damage: every Damage Event is created, in order of play, before any reaction. + const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); + const events: Event[] = []; + for (const target of targets) { + world.damage(target, action.amount); + events.push({ type: "Damaged", target, amount: action.amount }); + } + react(world, events); + break; + } + case "draw": + world.draw(action.player); + break; + case "gainArmor": + world.gainArmor(action.player, action.amount); + break; + case "gainAttack": + world.gainAttack(action.id, action.amount); + break; + case "destroy": + for (const target of action.targets) world.markDestroyed(target); + break; + case "summon": + summon(world, action.card, action.player, action.at); + break; + case "play": { + if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`); + // Playing is not summoning: no Aura Update here. The played minion's own + // auras join at the end of the outermost Phase. + world.moveTo(action.id, "board"); + const played: Event = { type: "Played", id: action.id }; + world.record(played); + react(world, [played]); + break; + } + case "cast": { + const spell = world.card(action.card); + if (spell.def.type !== "spell") throw new Error(`"${action.card}" is not a spell`); + world.record({ type: "SpellCast", card: action.card }); + runEffects(world, spell.onCast, new Ctx(world, action.player, undefined, action.target)); + break; + } + case "assemble": + assembleFromPool(world, action.player, action.filter); + break; + case "buff": + world.buff(action.id, action.attack, action.health); + break; + case "spawnText": + world.record({ type: "TextSpawned", source: action.source, word: action.word }); + break; + } +} + +/** + * Interpret and perform an ability's effects in printed order. Each effect sees the + * world as the effects before it left it. Depth-first: each action, and every + * reaction it causes, finishes before the next. + */ +function runEffects(world: World, effects: Effect[], ctx: Ctx): void { + for (const effect of effects) { + for (const action of actions(effect, ctx)) perform(world, action); + } +} + +/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */ +function react(world: World, events: Event[]): void { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + const queue: [EntityId, Trigger, Event][] = []; + for (const event of events) { + for (const listener of listeners(world, event)) { + for (const trigger of world.card(world.cardName(listener)).triggers) { + if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]); + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, trigger, event] of queue) { + // The interceptor: the entry was queued once; the auras in force when it + // starts decide how many times it activates, back to back. + const times = activations(world, world.ownerOf(source), trigger.on); + for (let i = 0; i < times; i++) { + world.record({ type: "Triggered", source, on: trigger.on }); + const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source); + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + } + } +} + +/** + * Who may respond to `event`, in order of play: every minion in play and, for a + * Death Event, the minion that died. It has left play, but its Deathrattle still answers. + */ +function listeners(world: World, event: Event): EntityId[] { + const inPlay = world.minionsInPlay(); + if (event.type !== "Destroyed") return inPlay; + return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); +} + +/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */ +function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean { + if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener; + if (event.type === "Played") return on === "battlecry" && event.id === listener; + if (event.type !== "Damaged") return false; + const isMinion = !world.isHero(event.target); + switch (on) { + case "selfDamaged": + return event.target === listener; + case "friendlyMinionDamaged": + return isMinion && world.ownerOf(event.target) === world.ownerOf(listener); + case "anyMinionDamaged": + return isMinion; + case "heroDamaged": + return !isMinion; + case "deathrattle": + case "battlecry": + case "endOfTurn": + return false; + } +} diff --git a/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts b/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts new file mode 100644 index 0000000..9a5e29b --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/spellgrid.ts @@ -0,0 +1,229 @@ +// Part III: SpellGrid, the two engines in one level. +// +// Part I (the card engine) and Part II (the grid engine, `puzzle/`) don't import each +// other. This file is the only place that knows both, and they meet at three seams: +// +// 1. Rules become modifiers. After every reparse, the rules in force that join a +// trigger word to a multiplier word (`DEATHRATTLE IS DOUBLE`) are compiled into +// auras, and handed to Part I with `World.setRuleAuras`. A negated one +// (`DEATHRATTLE IS NOT DOUBLE`) becomes a cap. Which words are which is content's +// data (`SeamWords`), not code here. +// 2. Hazards mark, the death step removes. The grid engine hands the objects a turn +// destroys to `destroy` below. A minion among them is marked pending destroy, in +// one Sequence, and Part I's Death Creation Step removes it. +// 3. Grid changes reparse. While a Sequence runs, the grid doesn't change. When it +// ends, the text its effects asked for (`TextSpawned` events) is written, the +// minions that died (`Destroyed` events) leave the grid, and the rules are parsed +// again, before anything else happens. +// +// One id space: a minion's id is its grid object's id. Every other card (a hero, a +// card in hand) gets an id from the same counter that no object will have. + +import { auraUpdateOther } from "./aura"; +import type { Aura, CardLibrary, TriggerOn } from "./card"; +import type { Event } from "./event"; +import * as puzzle from "./puzzle"; +import { runSequence, type Action } from "./queue"; +import { World, type EntityId } from "./world"; + +/** + * The words that cross the seam, from content: nouns that name a trigger + * (`"DEATHRATTLE": "deathrattle"`) and properties that multiply one + * (`"DOUBLE": { "times": 2, "stacking": "max" }`). + */ +export interface SeamWords { + triggerWords: Record; + multiplierWords: Record; +} + +/** + * Seam 1: the auras the rules in force give. `T IS M` (T a trigger word, M a multiplier + * word) multiplies T's triggers; `T IS NOT M` caps them at one activation, so it + * cancels a card's multiplier too, as `X IS NOT Y` cancels every `X IS Y`. + */ +export function ruleAuras(rules: readonly puzzle.Rule[], words: SeamWords): Aura[] { + const auras: Aura[] = []; + for (const rule of rules) { + const trigger = words.triggerWords[rule.subject]; + const multiplier = words.multiplierWords[rule.complement]; + if (trigger === undefined || multiplier === undefined) continue; + auras.push( + rule.negated + ? { type: "triggerCap", trigger, times: 1 } + : { type: "triggerMultiplier", trigger, ...multiplier }, + ); + } + return auras; +} + +/** A SpellGrid level in play: a grid and a game, kept in step. Ids are grid ids (see above). */ +export class SpellGrid { + readonly world: World; + readonly level: puzzle.Level; + /** Every entity's id. */ + private readonly ids = new Map(); + /** The entity behind each id (a card's old id still finds it after it is played). */ + private readonly entities = new Map(); + + /** + * The level's objects; its `cards` as minions in play for player 0, each a grid + * object named by the card's noun; its `hand` in player 0's hand. + */ + constructor( + def: puzzle.LevelDef, + library: CardLibrary, + words: readonly puzzle.WordDef[], + responses: readonly puzzle.Response[], + hazards: readonly puzzle.Hazard[], + private readonly seamWords: SeamWords, + ) { + this.world = new World(library); + this.level = new puzzle.Level(def, words, responses, hazards); + for (const { card, x, y } of def.cards ?? []) { + const id = this.level.board.place(this.nounOf(card), x, y); + this.link(this.world.create(card, 0, "board"), id); + } + // Every minion is in play: one Aura Update, as for a summon. + auraUpdateOther(this.world); + for (const card of def.hand ?? []) this.world.create(card, 0, "hand"); + this.linkTheRest(); + this.gridChanged(); + } + + // ---- Ids ---- + + /** The id of entity `entity`. */ + idOf(entity: EntityId): puzzle.ObjectId { + const id = this.ids.get(entity); + if (id === undefined) throw new Error(`entity ${entity} has no id`); + return id; + } + + /** The entity behind `id`. Throws if `id` is not a card's. */ + entity(id: puzzle.ObjectId): EntityId { + const entity = this.entities.get(id); + if (entity === undefined) throw new Error(`${id} is not a card`); + return entity; + } + + /** The id of the minion on tile (x, y), if there is one. */ + minionAt(x: number, y: number): puzzle.ObjectId | undefined { + return this.level.board.at(x, y).find((o) => this.entities.has(o.id))?.id; + } + + private link(entity: EntityId, id: puzzle.ObjectId): void { + this.ids.set(entity, id); + this.entities.set(id, entity); + } + + /** Give every entity that has no id one that no object will have. */ + private linkTheRest(): void { + for (let entity = 0; entity < this.world.entityCount(); entity++) { + if (!this.ids.has(entity)) this.link(entity, this.level.board.reserveId()); + } + } + + private nounOf(card: string): puzzle.Kind { + const { def } = this.world.card(card); + if (def.type !== "minion" || def.noun === undefined) throw new Error(`"${card}" is not a minion with a noun`); + return puzzle.Kind.parse(def.noun.toLowerCase()); + } + + // ---- What the player does ---- + + /** Add an object or a piece of text, then reparse. */ + place(kind: string, x: number, y: number): puzzle.ObjectId { + const id = this.level.place(kind, x, y); + this.gridChanged(); + return id; + } + + /** One whole turn (Module 9); what it destroys goes through seam 2. */ + step(dir: puzzle.Dir): void { + this.level.step(dir, this.destroy); + this.gridChanged(); + } + + /** Movement only (Module 8), then a reparse. */ + move(dir: puzzle.Dir): void { + this.level.moveYou(dir, this.destroy); + this.gridChanged(); + } + + /** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. */ + cast(card: string, target?: puzzle.ObjectId): void { + const inHand = this.fromHand(card, "spell"); + this.world.moveTo(inHand, "graveyard"); + const chosen = target === undefined ? undefined : this.entity(target); + this.sequence({ type: "cast", card, player: 0, target: chosen }); + } + + /** Play a minion from player 0's hand onto the empty tile (x, y), as one Sequence. Returns its new id. */ + play(card: string, x: number, y: number): puzzle.ObjectId { + const inHand = this.fromHand(card, "minion"); + if (this.level.board.at(x, y).length > 0) throw new Error(`(${x}, ${y}) is not empty`); + const id = this.level.board.place(this.nounOf(card), x, y); + this.link(inHand, id); + this.sequence({ type: "play", id: inHand }); + return id; + } + + private fromHand(card: string, type: "minion" | "spell"): EntityId { + const inHand = this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card); + if (inHand === undefined) throw new Error(`"${card}" is not in the hand`); + if (this.world.card(card).def.type !== type) throw new Error(`"${card}" is not a ${type}`); + return inHand; + } + + // ---- The seams ---- + + /** + * Seam 2. Objects that aren't cards leave the board. Minions are marked pending + * destroy, in one Sequence, under the rules as they stand now. + */ + private readonly destroy: puzzle.Destroy = (level, ids) => { + const present = [...new Set(ids)].filter((id) => level.board.find(id) !== undefined); + const minions = present.filter((id) => this.entities.has(id)); + for (const id of present) if (!this.entities.has(id)) level.board.remove(id); + if (minions.length === 0) return; + this.syncRules(); + this.sequence({ type: "destroy", targets: minions.map((id) => this.entity(id)) }); + }; + + /** Run one Sequence in Part I, then seam 3: apply its grid changes and reparse. */ + private sequence(action: Action): void { + const from = this.world.log().length; + runSequence(this.world, action); + const events = this.world.log().slice(from); + const board = this.level.board; + // Text first, while every minion that wrote it is still on its tile. + for (const event of events) { + if (event.type !== "TextSpawned") continue; + // A minion with no id (summoned by this Sequence) is not on the grid: nowhere to write. + const id = this.ids.get(event.source); + const source = id === undefined ? undefined : board.find(id); + if (source !== undefined) board.place({ type: "text", word: event.word }, source.x, source.y); + } + for (const event of deaths(events)) { + const id = this.ids.get(event.id); + if (id !== undefined) board.remove(id); + } + this.linkTheRest(); + this.gridChanged(); + } + + /** The grid changed: reparse it, then seam 1. */ + private gridChanged(): void { + this.level.reparse(); + this.syncRules(); + } + + /** Seam 1: compile the rules in force into auras for Part I, then run an Aura Update. */ + private syncRules(): void { + this.world.setRuleAuras(ruleAuras(this.level.activeRules(), this.seamWords)); + auraUpdateOther(this.world); + } +} + +const deaths = (events: readonly Event[]) => + events.filter((e): e is Extract => e.type === "Destroyed"); diff --git a/course/10-spellgrid/reference/ts/src/engine/world.ts b/course/10-spellgrid/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..daedb91 --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/engine/world.ts @@ -0,0 +1,334 @@ +// Entities, zones, order of play, stats and the event log: the game's state. +// The rules for resolving actions live in `queue.ts`. + +import type { Aura, Card, CardDef, CardLibrary } from "./card"; +import type { Event } from "./event"; +import { END_OF_PHASE, type EndOfPhaseStep } from "./queue"; + +/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */ +export type EntityId = number; +export type PlayerId = 0 | 1; +export type Zone = "deck" | "hand" | "board" | "graveyard"; + +/** Starting Health of each hero. */ +export const HERO_HEALTH = 30; + +interface Entity { + card: string; + owner: PlayerId; + zone: Zone; + /** Stamped each time the entity enters the board; undefined until it first does. */ + orderOfPlay?: number; + attack: number; + /** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */ + health: number; + /** Hit by a destroy effect: still in play until the Death Creation Step. */ + pendingDestroy: boolean; +} + +/** An aura in force: which player's minion has it. */ +export interface ActiveAura { + owner: PlayerId; + aura: Aura; +} + +/** + * Makes a player's choices: called with what is being chosen and the options, it + * returns one of the options. Hearthstone would ask the player or pick at random. + */ +export type Chooser = (kind: string, options: readonly string[]) => string; + +/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */ +type Side = Record & { hero: EntityId; armor: number }; + +export class World { + /** Indexed by EntityId. */ + private readonly entities: Entity[] = []; + private readonly sides: [Side, Side]; + private nextOrderOfPlay = 0; + private events: Event[] = []; + /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ + private auras: ActiveAura[] = []; + /** Until a chooser is set, take the first option. */ + private chooser: Chooser = (_kind, options) => options[0]; + /** + * Module 10: auras that no minion carries, for every player's minions. SpellGrid + * compiles them from the rules on its grid. The next Aura Update brings them in. + */ + private rules: Aura[] = []; + /** Module 10: the steps at the end of the outermost Phase, in order. */ + private endSteps: readonly EndOfPhaseStep[] = END_OF_PHASE; + + /** A new game: both heroes are in play from the start, so they are the oldest entities. */ + constructor(private readonly library: CardLibrary) { + const side = (owner: PlayerId): Side => { + const hero = this.entities.length; + this.entities.push({ + card: "Hero", + owner, + zone: "board", + orderOfPlay: ++this.nextOrderOfPlay, + attack: 0, + health: HERO_HEALTH, + pendingDestroy: false, + }); + // In play, but not on the board list: the board holds minions only. + return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 }; + }; + this.sides = [side(0), side(1)]; + } + + /** + * Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board), + * or at index `at` when given (clamped to the zone's length). + */ + create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + const { def } = this.library.get(card); + const id = this.entities.length; + this.entities.push({ + card: def.name, + owner, + zone, + attack: def.attack ?? 0, + health: def.health ?? 0, + pendingDestroy: false, + }); + this.enter(id, zone, at); + return id; + } + + /** Move an entity to the end of `zone` on its owner's side, keeping its id. */ + moveTo(id: EntityId, zone: Zone): void { + const { owner, zone: from } = this.entity(id); + const list = this.sides[owner][from]; + list.splice(list.indexOf(id), 1); + this.enter(id, zone); + } + + /** The single place an entity arrives in a zone. Entering the board starts a new order of play. */ + private enter(id: EntityId, zone: Zone, at?: number): void { + const entity = this.entity(id); + const list = this.sides[entity.owner][zone]; + list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id); + entity.zone = zone; + if (zone === "board") { + entity.orderOfPlay = ++this.nextOrderOfPlay; + this.record({ type: "Summoned", id }); + } + } + + private entity(id: EntityId): Entity { + const entity = this.entities[id]; + if (!entity) throw new Error(`unknown entity id ${id}`); + return entity; + } + + // ---- Queries ---- + + card(name: string): Card { + return this.library.get(name); + } + + hasCard(name: string): boolean { + return this.library.has(name); + } + + /** The cards an Assemble may offer, sorted by name. */ + pool(): Card[] { + return this.library.pool(); + } + + zoneOf(id: EntityId): Zone { + return this.entity(id).zone; + } + + cardName(id: EntityId): string { + return this.entity(id).card; + } + + ownerOf(id: EntityId): PlayerId { + return this.entity(id).owner; + } + + /** When the entity last entered the board (larger is later); undefined if it never has. */ + orderOfPlay(id: EntityId): number | undefined { + return this.entity(id).orderOfPlay; + } + + /** The ids in one player's zone, in zone order (left to right for the board). */ + zone(owner: PlayerId, zone: Zone): readonly EntityId[] { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + return this.sides[owner][zone]; + } + + hero(owner: PlayerId): EntityId { + if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`); + return this.sides[owner].hero; + } + + isHero(id: EntityId): boolean { + return this.sides.some((s) => s.hero === id); + } + + /** Every minion on either board, oldest first (order of play). */ + minionsInPlay(): EntityId[] { + return [...this.sides[0].board, ...this.sides[1].board].sort( + (a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!, + ); + } + + attack(id: EntityId): number { + return this.entity(id).attack; + } + + health(id: EntityId): number { + return this.entity(id).health; + } + + /** The cost printed on the entity's card. */ + cost(id: EntityId): number { + return this.card(this.cardName(id)).def.cost; + } + + /** The minions in a player's hand, in hand order. */ + minionsInHand(owner: PlayerId): EntityId[] { + return this.zone(owner, "hand").filter((id) => this.card(this.cardName(id)).def.type === "minion"); + } + + /** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */ + isDoomed(id: EntityId): boolean { + const entity = this.entity(id); + return entity.health <= 0 || entity.pendingDestroy; + } + + /** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */ + boardIndex(id: EntityId): number | undefined { + const index = this.sides[this.ownerOf(id)].board.indexOf(id); + return index < 0 ? undefined : index; + } + + armor(owner: PlayerId): number { + return this.sides[owner].armor; + } + + activeAuras(): readonly ActiveAura[] { + return this.auras; + } + + /** Auras from outside the cards (Module 10), as last set. */ + ruleAuras(): readonly Aura[] { + return this.rules; + } + + /** The steps at the end of the outermost Phase, in order. */ + endOfPhase(): readonly EndOfPhaseStep[] { + return this.endSteps; + } + + /** How many entities exist: ids run from 0 to this, exclusive. */ + entityCount(): number { + return this.entities.length; + } + + log(): readonly Event[] { + return this.events; + } + + // ---- Changes: each one records its event ---- + + record(event: Event): void { + this.events.push(event); + } + + /** Add a card to the library, or replace the card with the same name. */ + register(def: CardDef): void { + this.library.register(def); + } + + /** Add a card made by Assemble (see `assemble.ts`). */ + registerAssembled(def: CardDef): void { + this.library.registerAssembled(def); + } + + setChooser(chooser: Chooser): void { + this.chooser = chooser; + } + + /** Ask the chooser to pick one of `options` (not empty). Throws if it answers something that isn't an option. */ + choose(kind: string, options: readonly string[]): string { + const answer = this.chooser(kind, options); + if (!options.includes(answer)) { + throw new Error(`chooser answered "${answer}" for "${kind}"; the options were ${JSON.stringify(options)}`); + } + return answer; + } + + /** Replace the auras from outside the cards. They take effect at the next Aura Update. */ + setRuleAuras(auras: Aura[]): void { + this.rules = auras; + } + + /** Change the order of the steps at the end of the outermost Phase. */ + setEndOfPhase(steps: readonly EndOfPhaseStep[]): void { + this.endSteps = steps; + } + + /** Only an Aura Update calls this. */ + setActiveAuras(auras: ActiveAura[]): void { + this.auras = auras; + } + + clearLog(): void { + this.events = []; + } + + damage(target: EntityId, amount: number): void { + this.entity(target).health -= amount; + this.record({ type: "Damaged", target, amount }); + } + + /** + * Draw the top card of `player`'s deck (the first in the list). An empty deck + * records `Fatigue` and changes nothing else. + */ + draw(player: PlayerId): void { + const id = this.zone(player, "deck")[0]; + if (id === undefined) { + this.record({ type: "Fatigue", player }); + return; + } + this.moveTo(id, "hand"); + this.record({ type: "Drew", player, id }); + } + + /** Mark for destruction. Nothing leaves play until the Death Creation Step. */ + markDestroyed(id: EntityId): void { + this.entity(id).pendingDestroy = true; + } + + /** Move a minion from play to its owner's graveyard, recording its Death Event. */ + kill(id: EntityId, position: number): void { + this.entity(id).pendingDestroy = false; + this.moveTo(id, "graveyard"); + this.record({ type: "Destroyed", id, position }); + } + + gainArmor(player: PlayerId, amount: number): void { + this.sides[player].armor += amount; + this.record({ type: "ArmorGained", player, amount }); + } + + buff(id: EntityId, attack: number, health: number): void { + const entity = this.entity(id); + entity.attack += attack; + entity.health += health; + this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health }); + } + + gainAttack(id: EntityId, amount: number): void { + const entity = this.entity(id); + entity.attack += amount; + this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health }); + } +} diff --git a/course/10-spellgrid/reference/ts/src/harness.ts b/course/10-spellgrid/reference/ts/src/harness.ts new file mode 100644 index 0000000..15db9cf --- /dev/null +++ b/course/10-spellgrid/reference/ts/src/harness.ts @@ -0,0 +1,250 @@ +// Test harness: the thin adapter the course tests call. It translates each +// call into engine operations and holds no rules of its own. + +import { cards, hazards, movement, seams, words } from "./content"; +import { + assemble, + CardLibrary, + END_OF_PHASE, + SpellGrid as Grid, + puzzle, + registerAssembled, + runSequence, + summon, + triggerLabel, + World, + type CardDef, + type Chooser, + type Event, + type Half, + type Zone, +} from "./engine"; + +export { AssembleError } from "./engine"; +export type { CardDef, Chooser, Half, Zone }; + +export type Id = number; +export type Player = 0 | 1; + +/** The events the tests read from `log()` (see course/harness.md). */ +export type GameEvent = + | { type: "Summoned"; id: Id } + | { type: "Damaged"; target: Id; amount: number } + | { type: "Triggered"; source: Id; trigger: string } + | { type: "Destroyed"; id: Id } + | { type: "DeathPhase"; round: number } + | { type: "Drew"; player: Player; id: Id } + | { type: "Fatigue"; player: Player } + | { type: "ArmorGained"; player: Player; amount: number } + | { type: "StatsChanged"; id: Id; attack: number; health: number } + | { type: "SpellCast"; card: string }; + +/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */ +function toGameEvent(event: Event): GameEvent | undefined { + if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) }; + if (event.type === "Destroyed") return { type: "Destroyed", id: event.id }; + // A played minion's `Summoned` already shows it entering the board. + if (event.type === "Played") return undefined; + // SpellGrid writes the text on the grid; the course's log doesn't show the request. + if (event.type === "TextSpawned") return undefined; + return { ...event }; +} + +export interface Game { + summon(card: string, player?: Player): Id; // to right end of board + addToHand(card: string, player?: Player): Id; + addToDeck(card: string, player?: Player): Id; + play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry) + zoneOf(id: Id): Zone; + board(player: Player): Id[]; // left to right + hand(player: Player): Id[]; + orderOfPlay(id: Id): number; + nameOf(id: Id): string; + // Module 2 + hero(player: Player): Id; + dealDamage(target: Id, amount: number): void; // one Sequence + cast(card: string, player?: Player, target?: Id): void; + health(id: Id): number; + attack(id: Id): number; + armor(player: Player): number; + log(): GameEvent[]; + clearLog(): void; + // Module 3 + destroy(target: Id): void; // marks pending destroy, one Sequence + // Module 5 + registerCards(defs: CardDef[]): void; // add card definitions, replacing any with the same name + assemble(base: string, donor: string, half: Half): string; // registers " (Assembled)"; throws AssembleError + cost(id: Id): number; + setChooser(choose: Chooser): void; // makes Assemble's choices: kind "base" | "donor" | "half" +} + +export function newGame(opts?: { decks?: [string[], string[]] }): Game { + const world = new World(new CardLibrary(cards())); + opts?.decks?.forEach((deck, player) => { + for (const card of deck) world.create(card, player as Player, "deck"); + }); + + return { + summon: (card, player = 0) => summon(world, card, player), + addToHand: (card, player = 0) => world.create(card, player, "hand"), + addToDeck: (card, player = 0) => world.create(card, player, "deck"), + // Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves. + play: (handId) => runSequence(world, { type: "play", id: handId }), + zoneOf: (id) => world.zoneOf(id), + board: (player) => [...world.zone(player, "board")], + hand: (player) => [...world.zone(player, "hand")], + orderOfPlay: (id) => { + const order = world.orderOfPlay(id); + if (order === undefined) throw new Error(`entity ${id} has not entered play`); + return order; + }, + nameOf: (id) => world.cardName(id), + + hero: (player) => world.hero(player), + dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }), + cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }), + health: (id) => world.health(id), + attack: (id) => world.attack(id), + armor: (player) => world.armor(player), + log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), + clearLog: () => world.clearLog(), + + destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), + + registerCards: (defs) => defs.forEach((def) => world.register(def)), + assemble: (base, donor, half) => + registerAssembled(world, assemble(world.card(base).def, world.card(donor).def, half)), + cost: (id) => world.cost(id), + setChooser: (choose) => world.setChooser(choose), + }; +} + +// ======== Part II: Baba Is You ======== + +export type { Dir, LevelCard, LevelDef, LevelObject } from "./engine/puzzle"; + +/** A Baba Is You level in play (Part II). */ +export interface Baba { + // Module 6 + place(kind: string, x: number, y: number): Id; // "baba" or "text:BABA" + rules(): string[]; // distinct, normalised ("BABA IS YOU", "BABA IS NOT YOU"), sorted; before resolution + // Module 7 + activeRules(): string[]; // the same, after resolution (NOT, identity) + hasProperty(id: Id, prop: string): boolean; + // Module 8 + move(dir: puzzle.Dir): void; // every YOU object one tile, with what it pushes; then reparse. Movement only. + posOf(id: Id): [number, number] | null; // null once destroyed + kindsAt(x: number, y: number): string[]; // kinds as placed ("baba", "text:ROCK"), sorted + // Module 9 + step(dir: puzzle.Dir): void; // one whole turn: movement, parse, conversion, parse, hazards, win (then parse) + won(): boolean; // a turn found a YOU object on a WIN one (or WIN itself); stays true + lost(): boolean; // no object is YOU +} + +/** Distinct rules as normalised strings, sorted. */ +const normalised = (rules: readonly puzzle.Rule[]): string[] => [...new Set(rules.map(puzzle.ruleText))].sort(); + +/** A level from a level definition; its text is parsed straight away. */ +export function newBaba(level: puzzle.LevelDef): Baba { + const lvl = new puzzle.Level(level, words(), movement(), hazards()); + return { + place: (kind, x, y) => lvl.place(kind, x, y), + rules: () => normalised(lvl.rules()), + activeRules: () => normalised(lvl.activeRules()), + hasProperty: (id, prop) => lvl.hasProperty(id, prop), + move: (dir) => lvl.moveYou(dir), + posOf: (id) => { + const o = lvl.board.find(id); + return o === undefined ? null : [o.x, o.y]; + }, + kindsAt: (x, y) => + lvl.board + .at(x, y) + .map((o) => puzzle.Kind.format(o.kind)) + .sort(), + step: (dir) => lvl.step(dir), + won: () => lvl.won(), + lost: () => lvl.lost(), + }; +} + +// ======== Part III: SpellGrid ======== + +/** + * A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards. + * Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.ts`). + */ +export interface SpellGrid extends Baba { + // Module 10 + cast(card: string, target?: Id): void; // a spell from player 0's hand, as one Sequence; target: a minion + play(card: string, x: number, y: number): Id; // a minion from player 0's hand onto an empty tile + health(id: Id): number; + hero(player: Player): Id; + nameOf(id: Id): string; + minionAt(x: number, y: number): Id | null; + hand(): string[]; // card names in player 0's hand, in order + log(): GameEvent[]; // Part I's log, with grid ids + clearLog(): void; + setChooser(choose: Chooser): void; // makes Assemble's choices + withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step +} + +/** A SpellGrid level; `extra` cards are added to the course's cards, replacing any with the same name. */ +export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGrid { + const library = new CardLibrary(cards()); + for (const def of extra) library.register(def); + const g = new Grid(level, library, words(), movement(), hazards(), seams()); + const lvl = g.level; + /** An engine event with every entity id as a grid id. */ + const withIds = (event: GameEvent): GameEvent => { + switch (event.type) { + case "Summoned": + case "Destroyed": + case "Drew": + case "StatsChanged": + return { ...event, id: g.idOf(event.id) }; + case "Damaged": + return { ...event, target: g.idOf(event.target) }; + case "Triggered": + return { ...event, source: g.idOf(event.source) }; + default: + return event; + } + }; + return { + place: (kind, x, y) => g.place(kind, x, y), + rules: () => normalised(lvl.rules()), + activeRules: () => normalised(lvl.activeRules()), + hasProperty: (id, prop) => lvl.hasProperty(id, prop), + move: (dir) => g.move(dir), + posOf: (id) => { + const o = lvl.board.find(id); + return o === undefined ? null : [o.x, o.y]; + }, + kindsAt: (x, y) => + lvl.board + .at(x, y) + .map((o) => puzzle.Kind.format(o.kind)) + .sort(), + step: (dir) => g.step(dir), + won: () => lvl.won(), + lost: () => lvl.lost(), + + cast: (card, target) => g.cast(card, target), + play: (card, x, y) => g.play(card, x, y), + health: (id) => g.world.health(g.entity(id)), + hero: (player) => g.idOf(g.world.hero(player)), + nameOf: (id) => g.world.cardName(g.entity(id)), + minionAt: (x, y) => g.minionAt(x, y) ?? null, + hand: () => g.world.zone(0, "hand").map((id) => g.world.cardName(id)), + log: () => + g.world + .log() + .flatMap((event) => toGameEvent(event) ?? []) + .map(withIds), + clearLog: () => g.world.clearLog(), + setChooser: (choose) => g.world.setChooser(choose), + // The naive engine reads the auras before the dead leave play: the same steps, reversed. + withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()), + }; +} diff --git a/course/10-spellgrid/reference/ts/tests/cli.test.ts b/course/10-spellgrid/reference/ts/tests/cli.test.ts new file mode 100644 index 0000000..9b855b4 --- /dev/null +++ b/course/10-spellgrid/reference/ts/tests/cli.test.ts @@ -0,0 +1,32 @@ +// The SpellGrid command line, played from a script (the reference's own check; not a course test). +import { readFileSync } from "node:fs"; +import { expect, test } from "vitest"; +import { run } from "../src/cli"; + +function play(level: string, input: string[]): { outcome: string; out: string } { + let out = ""; + const outcome = run(readFileSync(`levels/${level}.json`, "utf8"), () => input.shift(), (s) => (out += s)); + return { outcome, out }; +} + +test("cli_wins_level_1", () => { + const { outcome, out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "s", "s", "a", "s", "s", "d", "d"]); + expect(outcome).toBe("won"); + expect(out).toContain("DEA IS DOU"); + expect(out).toContain("You win!"); +}); + +test("cli_wins_level_2_with_choices_and_play", () => { + const input = ["c Graft", "Ember Moth", "Rune Scribe", "bottom", "c Ember Moth (Assembled) 4 3", "a", "a"]; + const { outcome, out } = play("l2-spliced-corpse", input); + expect(outcome).toBe("won"); + expect(out).toContain("Choose the base"); + expect(out).toContain("Hand: Ember Moth (Assembled)"); +}); + +test("cli_reports_errors_and_quits", () => { + const { outcome, out } = play("l3-simultaneous-paradox", ["c Spark", "x", "q"]); + expect(outcome).toBe("quit"); + expect(out).toContain('"Spark" is not in the hand'); + expect(out).toContain("Commands:"); +}); diff --git a/course/10-spellgrid/reference/ts/tsconfig.json b/course/10-spellgrid/reference/ts/tsconfig.json new file mode 100644 index 0000000..604c7c5 --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/reference/ts/vitest.config.ts b/course/10-spellgrid/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/10-spellgrid/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/10-spellgrid/scenarios.md b/course/10-spellgrid/scenarios.md new file mode 100644 index 0000000..3c34930 --- /dev/null +++ b/course/10-spellgrid/scenarios.md @@ -0,0 +1,137 @@ +# Module 10 scenarios: SpellGrid + +Each heading is a test name. The Rust test lives in `tests/rust/test_10.rs` and the TypeScript test in `tests/ts/test_10.test.ts`; both state exactly what is written here. + +SpellGrid is the course's own small game: Part I's card engine and Part II's grid engine in one level. The harness adds one constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`: + +- Every Part II function works as in Modules 6–9: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`. +- The level format gains two optional fields (`course/schema/level.schema.json`): `cards`, a list of `{ card, x, y }`, and `hand`, a list of card names. Each entry of `cards` is a minion in play for player 0 **and** a grid object at (x, y) whose noun is the card's `"noun"` field (`"noun": "KEG"` makes an object of kind `"keg"`). Each name in `hand` is a card in player 0's hand. +- `cards` (the constructor's second argument) is a list of extra card definitions, added to the course's cards. Every test here passes an empty list. +- One id space: a minion's id is its grid object's id. `place`, `posOf`, `hasProperty` and `health` all take it, and `log()` reports it. A hero, or a card that isn't on the grid, gets an id no grid object has. +- `cast(card, target?)` (Rust: `cast(card, Some(target))` or `None`) casts a spell from player 0's hand, as one Sequence. The card leaves the hand. Casting a card that isn't in the hand is an error. +- `play(card, x, y)` plays a minion from player 0's hand onto the empty tile (x, y), as one Sequence (Module 4's `play`: it enters the board, then its Battlecry resolves), and returns its id. Playing onto a tile that holds anything is an error. +- `setChooser(choose)` is Module 5's: it makes Assemble's choices (`"base"`, `"donor"`, `"half"`). +- `health(id)`, `hero(player)`, `nameOf(id)`, `log()` and `clearLog()` are Part I's, with ids translated as above. `minionAt(x, y)` is the id of the minion on that tile, or `null` (Rust: `Option`). `hand()` lists the card names in player 0's hand, in order. +- `withNaiveAuraTiming()` is a test hook (Rust: `with_naive_aura_timing()`). It reverses the two steps at the end of an outermost Phase, so that the Aura Update runs *before* the Death Creation Step and not after it. Only `test_10_level_3_naive_would_differ` calls it. + +The three seams between the engines, each tested below: + +1. **Rules become modifiers.** A rule in force ` IS DOUBLE` (`DEATHRATTLE IS DOUBLE`) is a trigger multiplier like Baron Rivendare's (`times: 2`, `stacking: "max"`), for every player's minions. `DEATHRATTLE IS NOT DOUBLE` caps that trigger at one activation, whatever else multiplies it. The rule takes effect at the next Aura Update, like a minion's aura. +2. **Hazards mark, the death step removes.** A minion that a hazard (or `OPEN`/`SHUT`, or `X IS NOT X`) destroys is marked pending destroy, in one Sequence. Module 3's Death Creation Step removes it, and its Deathrattle resolves. +3. **Grid changes reparse.** A minion that leaves play leaves the grid, and an effect that writes on the grid (`spawnText`: "put the word W on this minion's tile") writes there. Both happen when the Sequence ends, and then the rules are parsed again, before anything else. + +Casting is not a turn: there is no win check until the next `step`. + +SpellGrid's own cards (`"game": "spellgrid"` in `course/catalog.json`; not Hearthstone cards): + +| Card | Type | Stats | Noun | Text | +|---|---|---|---|---| +| Powder Keg | minion, 2 | 1/1 | KEG | Deathrattle: Deal 1 damage to all minions. | +| Bramble Wall | minion, 1 | 0/3 | BRAMBLE | (none) | +| Thorn Hedge | minion, 1 | 0/2 | HEDGE | (none) | +| Grave Warden | minion, 3 | 1/1 | WARDEN | Your minions trigger their Deathrattles twice. (Baron-like, with 1 Health) | +| Ember Moth | minion, 1 | 1/1 | MOTH | Taunt. Deathrattle: Deal 2 damage to the enemy hero. (`split` 1) | +| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. (`split` 0) | +| Tin Knight | minion, 2 | 1/3 | KNIGHT | (none) | +| Spark | spell, 1 | | | Deal 1 damage to a minion. | +| Graft | spell, 2 | | | Assemble a Deathrattle minion. | + +Graft is Module 5's `assemble` effect: the chooser picks a base from the Deathrattle minions among the course's cards, a donor, and a half, and the Assembled minion goes to the hand. It keeps the base's name, stats and noun. Whirlwind ("Deal 1 damage to ALL minions.") is the Hearthstone card from Module 3. + +New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT` and `DEATHRATTLE`, and the property `DOUBLE`. A sentence like `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` is three text objects, as in Modules 6–9. + +### test_10_spatial_rule_feeds_interceptor + +- Setup: a level 6 wide and 3 high with `DEATHRATTLE (0,0) IS (1,0)` and nothing at (2,0); cards Powder Keg at (0,2) and Bramble Wall at (2,2); hand `["Spark"]`. +- Assertions 1: `activeRules()` doesn't contain `"DEATHRATTLE IS DOUBLE"`. +- Action 1: `place("text:DOUBLE", 2, 0)`. +- Assertions 2: `activeRules()` contains `"DEATHRATTLE IS DOUBLE"`. +- Action 2: `clearLog()`, then `cast("Spark", keg)`. +- Assertions 3: + - The log holds exactly two `Triggered { source: keg, trigger: "deathrattle" }`. + - `health(bramble)` is 1: two Deathrattles of 1 damage each. + - `posOf(keg)` is `null` and `posOf(bramble)` is (2,2). + - `hand()` is empty. + +### test_10_not_cancels_spatial_modifier + +- Setup: a level 6 wide and 4 high with `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` and `DEATHRATTLE (0,1) IS (1,1) NOT (2,1) DOUBLE (3,1)`; cards Grave Warden at (0,3), Powder Keg at (1,3) and Bramble Wall at (2,3); hand `["Spark"]`. +- Assertions 1: `rules()` contains `"DEATHRATTLE IS DOUBLE"` and `"DEATHRATTLE IS NOT DOUBLE"`; `activeRules()` contains `"DEATHRATTLE IS NOT DOUBLE"` but not `"DEATHRATTLE IS DOUBLE"`. +- Action: `clearLog()`, then `cast("Spark", keg)`. +- Assertions 2: + - The log holds exactly one `Triggered { source: keg, trigger: "deathrattle" }`, although the Grave Warden was in play when the keg died: the negated rule caps the card's aura too. + - `health(bramble)` is 2. + - `posOf(warden)` is `null`: the Deathrattle's 1 damage killed it, in the next Death Creation Step. + +### test_10_grid_and_card_doublers_take_max + +- Setup: a level 6 wide and 3 high with `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)`; cards Grave Warden at (0,2), Powder Keg at (1,2) and Bramble Wall at (2,2); hand `["Spark"]`. +- Action: `clearLog()`, then `cast("Spark", keg)`. +- Assertions: + - The log holds exactly two `Triggered { source: keg, trigger: "deathrattle" }`: the rule's multiplier and the Warden's both have `stacking: "max"`, so together they double, not quadruple. + - `health(bramble)` is 1. + +### test_10_hazard_feeds_death_step + +- Setup: a level 5 wide and 4 high with `KEG (0,0) IS (1,0) YOU (2,0) AND (3,0) MELT (4,0)` and `LAVA (0,1) IS (1,1) HOT (2,1)`; an object `lava` at (2,3); cards Powder Keg at (1,3) and Bramble Wall at (4,3). +- Action: `clearLog()`, then `step("right")`. +- Assertions: + - The log is exactly `[Destroyed { id: keg }, DeathPhase { round: 1 }, Triggered { source: keg, trigger: "deathrattle" }, Damaged { target: bramble, amount: 1 }]`: the keg melted, as a pending destroy that Module 3's Death Creation Step removed, and its Deathrattle resolved in the Death Phase. + - `posOf(keg)` is `null`, `kindsAt(2,3)` is `["lava"]` and `health(bramble)` is 2. + - `lost()` is true: the keg was the only `YOU` object. + +### test_10_spawned_text_triggers_reparse + +- Setup: a level 5 wide and 3 high with `BABA (0,0) IS (1,0) YOU (2,0)` and `BABA (0,1) IS (1,1)`; card Rune Scribe at (2,1), on the empty end of `BABA IS`; hand `["Spark"]`. Then `place("baba", 4, 2)`. +- Assertions 1: `rules()` doesn't contain `"BABA IS WIN"`. +- Action 1: `cast("Spark", scribe)`. +- Assertions 2: + - `kindsAt(2,1)` is `["text:WIN"]`: the scribe's Deathrattle wrote `WIN` on its tile, and the scribe left the grid. + - `rules()` contains `"BABA IS WIN"` and `hasProperty(baba, "WIN")` is true, straight after the cast: the grid was reparsed when the Sequence ended. + - `won()` is false: casting is not a turn. +- Action 2: `step("left")`. +- Assertions 3: `won()` is true. + +### test_10_level_1_solution + +Level `levels/l1-double-tap.json`. The Bramble Wall (3 Health) is `STOP` and blocks the only way to the flag. Whirlwind and one Powder Keg Deathrattle do 2 damage, one short. + +- Inputs: `step("up")`, `step("up")` (pushing `DOUBLE` through the gap in the wall to finish `DEATHRATTLE IS DOUBLE`), then `cast("Whirlwind")`. +- Assertions 1: `activeRules()` contains `"DEATHRATTLE IS DOUBLE"` before the cast. After it, the keg's Deathrattle has triggered twice and `posOf(bramble)` is `null`. +- Inputs: `step("down")`, `step("down")`, `step("left")`, `step("down")`, `step("down")`, `step("right")`. +- Assertions 2: `won()` is false, and `kindsAt(5,6)` is `["baba"]`: Baba stands where the bramble was. +- Input: `step("right")`. +- Assertions 3: `won()` is true. + +### test_10_level_2_solution + +Level `levels/l2-spliced-corpse.json`. `BABA IS` ends on a lava tile. `MOTH IS PUSH AND MELT` is written, but there is no moth: the hand holds only Graft. + +- Setup: `setChooser` answering `"Ember Moth"` for the base, `"Rune Scribe"` for the donor and `"bottom"` for the half. +- Input: `cast("Graft")`. +- Assertions 1: `hand()` is `["Ember Moth (Assembled)"]`: the moth's noun and Taunt, the scribe's Deathrattle. +- Input: `play("Ember Moth (Assembled)", 4, 3)`, returning `moth`. +- Assertions 2: `kindsAt(4,3)` is `["moth"]`, and `nameOf(moth)` is `"Ember Moth (Assembled)"`. +- Inputs: `step("left")`, then `step("left")`, which pushes the moth onto the lava. +- Assertions 3: + - `posOf(moth)` is `null` and `kindsAt(2,3)` is `["lava", "text:WIN"]`: the moth melted and its Deathrattle wrote `WIN` where it died. + - `rules()` contains `"BABA IS WIN"`, and `won()` is true **after that same step**: the reparse after the Sequence came before the win check. + +### test_10_level_3_solution + +Level `levels/l3-simultaneous-paradox.json`. The player is a Tin Knight (`KNIGHT IS YOU`, 3 Health). The Thorn Hedge (2 Health) is `STOP` and blocks the flag. The Grave Warden and the Powder Keg have 1 Health each. + +- Input: `cast("Whirlwind")`. +- Assertions 1: + - The keg's Deathrattle triggered exactly once: the Warden died in the same Death Creation Step, and the Aura Update after that step removed its aura before the Death Phase. + - `health(knight)` is 1, and `posOf(hedge)`, `posOf(keg)` and `posOf(warden)` are `null`. +- Inputs: `step("right")` five times. +- Assertions 2: `won()` is true. + +### test_10_level_3_naive_would_differ + +The same level and the same inputs, on a SpellGrid with `withNaiveAuraTiming()` called first. + +- Assertions: + - The keg's Deathrattle triggered twice: the Warden's aura was still in the snapshot when the Death Phase began. + - `posOf(knight)` is `null`, `lost()` is true and `won()` is false: the second Deathrattle killed the knight along with the hedge. This is the trap the level is built on. diff --git a/course/10-spellgrid/tests/rust/test_10.rs b/course/10-spellgrid/tests/rust/test_10.rs new file mode 100644 index 0000000..85a1142 --- /dev/null +++ b/course/10-spellgrid/tests/rust/test_10.rs @@ -0,0 +1,368 @@ +// Module 10: SpellGrid. Each test matches a heading in scenarios.md. +use engine::harness::*; + +/// A level from its JSON text (the course's level format). +fn level_json(json: &str) -> LevelDef { + serde_json::from_str(json).expect("a level in the course's level format") +} + +/// A level `width` wide and `height` high: text written as `(word, x, y)`, other +/// objects as `(kind, x, y)`, minion cards as `(card, x, y)`, and a hand. +fn level( + width: i32, + height: i32, + words: &[(&str, i32, i32)], + objects: &[(&str, i32, i32)], + cards: &[(&str, i32, i32)], + hand: &[&str], +) -> LevelDef { + let words = words.iter().map(|&(w, x, y)| (format!("text:{w}"), x, y)); + let objects = objects.iter().map(|&(k, x, y)| (k.to_string(), x, y)); + LevelDef { + width, + height, + objects: words + .chain(objects) + .map(|(kind, x, y)| LevelObject { kind, x, y }) + .collect(), + cards: cards + .iter() + .map(|&(card, x, y)| LevelCard { + card: card.to_string(), + x, + y, + }) + .collect(), + hand: hand.iter().map(|c| c.to_string()).collect(), + } +} + +/// The minion on tile (x, y); fails the test if there is none. +fn minion(g: &SpellGrid, x: i32, y: i32) -> Id { + g.minion_at(x, y) + .unwrap_or_else(|| panic!("no minion at ({x}, {y})")) +} + +/// How many times `source`'s Deathrattle triggered in the log. +fn deathrattles(g: &SpellGrid, source: Id) -> usize { + g.log() + .iter() + .filter(|e| { + matches!(e, GameEvent::Triggered { source: s, trigger } if *s == source && trigger == "deathrattle") + }) + .count() +} + +fn has(rules: &[String], rule: &str) -> bool { + rules.iter().any(|r| r == rule) +} + +#[test] +fn test_10_spatial_rule_feeds_interceptor() { + let mut g = new_spell_grid( + level( + 6, + 3, + &[("DEATHRATTLE", 0, 0), ("IS", 1, 0)], + &[], + &[("Powder Keg", 0, 2), ("Bramble Wall", 2, 2)], + &["Spark"], + ), + vec![], + ); + let keg = minion(&g, 0, 2); + let bramble = minion(&g, 2, 2); + + assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE")); + + g.place("text:DOUBLE", 2, 0); + + assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE")); + + g.clear_log(); + g.cast("Spark", Some(keg)); + + assert_eq!(deathrattles(&g, keg), 2); + assert_eq!(g.health(bramble), 1); + assert_eq!(g.pos_of(keg), None); + assert_eq!(g.pos_of(bramble), Some((2, 2))); + assert_eq!(g.hand(), Vec::::new()); +} + +#[test] +fn test_10_not_cancels_spatial_modifier() { + let mut g = new_spell_grid( + level( + 6, + 4, + &[ + ("DEATHRATTLE", 0, 0), + ("IS", 1, 0), + ("DOUBLE", 2, 0), + ("DEATHRATTLE", 0, 1), + ("IS", 1, 1), + ("NOT", 2, 1), + ("DOUBLE", 3, 1), + ], + &[], + &[ + ("Grave Warden", 0, 3), + ("Powder Keg", 1, 3), + ("Bramble Wall", 2, 3), + ], + &["Spark"], + ), + vec![], + ); + let warden = minion(&g, 0, 3); + let keg = minion(&g, 1, 3); + let bramble = minion(&g, 2, 3); + + assert!(has(&g.rules(), "DEATHRATTLE IS DOUBLE")); + assert!(has(&g.rules(), "DEATHRATTLE IS NOT DOUBLE")); + assert!(has(&g.active_rules(), "DEATHRATTLE IS NOT DOUBLE")); + assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE")); + + g.clear_log(); + g.cast("Spark", Some(keg)); + + assert_eq!(deathrattles(&g, keg), 1); + assert_eq!(g.health(bramble), 2); + assert_eq!(g.pos_of(warden), None); +} + +#[test] +fn test_10_grid_and_card_doublers_take_max() { + let mut g = new_spell_grid( + level( + 6, + 3, + &[("DEATHRATTLE", 0, 0), ("IS", 1, 0), ("DOUBLE", 2, 0)], + &[], + &[ + ("Grave Warden", 0, 2), + ("Powder Keg", 1, 2), + ("Bramble Wall", 2, 2), + ], + &["Spark"], + ), + vec![], + ); + let keg = minion(&g, 1, 2); + let bramble = minion(&g, 2, 2); + + g.clear_log(); + g.cast("Spark", Some(keg)); + + assert_eq!(deathrattles(&g, keg), 2); + assert_eq!(g.health(bramble), 1); +} + +#[test] +fn test_10_hazard_feeds_death_step() { + let mut g = new_spell_grid( + level( + 5, + 4, + &[ + ("KEG", 0, 0), + ("IS", 1, 0), + ("YOU", 2, 0), + ("AND", 3, 0), + ("MELT", 4, 0), + ("LAVA", 0, 1), + ("IS", 1, 1), + ("HOT", 2, 1), + ], + &[("lava", 2, 3)], + &[("Powder Keg", 1, 3), ("Bramble Wall", 4, 3)], + &[], + ), + vec![], + ); + let keg = minion(&g, 1, 3); + let bramble = minion(&g, 4, 3); + + g.clear_log(); + g.step(Dir::Right); + + assert_eq!( + g.log(), + vec![ + GameEvent::Destroyed { id: keg }, + GameEvent::DeathPhase { round: 1 }, + GameEvent::Triggered { + source: keg, + trigger: "deathrattle".to_string() + }, + GameEvent::Damaged { + target: bramble, + amount: 1 + }, + ] + ); + assert_eq!(g.pos_of(keg), None); + assert_eq!(g.kinds_at(2, 3), vec!["lava"]); + assert_eq!(g.health(bramble), 2); + assert!(g.lost()); +} + +#[test] +fn test_10_spawned_text_triggers_reparse() { + let mut g = new_spell_grid( + level( + 5, + 3, + &[ + ("BABA", 0, 0), + ("IS", 1, 0), + ("YOU", 2, 0), + ("BABA", 0, 1), + ("IS", 1, 1), + ], + &[], + &[("Rune Scribe", 2, 1)], + &["Spark"], + ), + vec![], + ); + let scribe = minion(&g, 2, 1); + let baba = g.place("baba", 4, 2); + + assert!(!has(&g.rules(), "BABA IS WIN")); + + g.cast("Spark", Some(scribe)); + + assert_eq!(g.kinds_at(2, 1), vec!["text:WIN"]); + assert!(has(&g.rules(), "BABA IS WIN")); + assert!(g.has_property(baba, "WIN")); + assert!(!g.won()); + + g.step(Dir::Left); + + assert!(g.won()); +} + +#[test] +fn test_10_level_1_solution() { + let mut g = new_spell_grid( + level_json(include_str!("../levels/l1-double-tap.json")), + vec![], + ); + let keg = minion(&g, 0, 4); + let bramble = minion(&g, 5, 6); + + g.step(Dir::Up); + g.step(Dir::Up); + + assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE")); + + g.cast("Whirlwind", None); + + assert_eq!(deathrattles(&g, keg), 2); + assert_eq!(g.pos_of(bramble), None); + + for dir in [ + Dir::Down, + Dir::Down, + Dir::Left, + Dir::Down, + Dir::Down, + Dir::Right, + ] { + g.step(dir); + } + + assert!(!g.won()); + assert_eq!(g.kinds_at(5, 6), vec!["baba"]); + + g.step(Dir::Right); + + assert!(g.won()); +} + +#[test] +fn test_10_level_2_solution() { + let mut g = new_spell_grid( + level_json(include_str!("../levels/l2-spliced-corpse.json")), + vec![], + ); + g.set_chooser(|kind, _| { + match kind { + "base" => "Ember Moth", + "donor" => "Rune Scribe", + _ => "bottom", + } + .to_string() + }); + + g.cast("Graft", None); + + assert_eq!(g.hand(), vec!["Ember Moth (Assembled)"]); + + let moth = g.play("Ember Moth (Assembled)", 4, 3); + + assert_eq!(g.kinds_at(4, 3), vec!["moth"]); + assert_eq!(g.name_of(moth), "Ember Moth (Assembled)"); + + g.step(Dir::Left); + g.step(Dir::Left); + + assert_eq!(g.pos_of(moth), None); + assert_eq!(g.kinds_at(2, 3), vec!["lava", "text:WIN"]); + assert!(has(&g.rules(), "BABA IS WIN")); + assert!(g.won()); +} + +/// Level 3's first input, one Whirlwind, after clearing the log. Returns the +/// knight, keg, warden and hedge. +fn cast_level_3_whirlwind(g: &mut SpellGrid) -> (Id, Id, Id, Id) { + let ids = ( + minion(g, 0, 4), + minion(g, 1, 5), + minion(g, 2, 5), + minion(g, 4, 4), + ); + g.clear_log(); + g.cast("Whirlwind", None); + ids +} + +#[test] +fn test_10_level_3_solution() { + let mut g = new_spell_grid( + level_json(include_str!("../levels/l3-simultaneous-paradox.json")), + vec![], + ); + let (knight, keg, warden, hedge) = cast_level_3_whirlwind(&mut g); + + assert_eq!(deathrattles(&g, keg), 1); + assert_eq!(g.health(knight), 1); + assert_eq!(g.pos_of(hedge), None); + assert_eq!(g.pos_of(keg), None); + assert_eq!(g.pos_of(warden), None); + + for _ in 0..5 { + g.step(Dir::Right); + } + + assert!(g.won()); +} + +#[test] +fn test_10_level_3_naive_would_differ() { + let mut g = new_spell_grid( + level_json(include_str!("../levels/l3-simultaneous-paradox.json")), + vec![], + ); + g.with_naive_aura_timing(); + let (knight, keg, _, _) = cast_level_3_whirlwind(&mut g); + for _ in 0..5 { + g.step(Dir::Right); + } + + assert_eq!(deathrattles(&g, keg), 2); + assert_eq!(g.pos_of(knight), None); + assert!(g.lost()); + assert!(!g.won()); +} diff --git a/course/10-spellgrid/tests/ts/test_10.test.ts b/course/10-spellgrid/tests/ts/test_10.test.ts new file mode 100644 index 0000000..c49673d --- /dev/null +++ b/course/10-spellgrid/tests/ts/test_10.test.ts @@ -0,0 +1,289 @@ +// Module 10: SpellGrid. Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newSpellGrid, type Id, type LevelDef, type SpellGrid } from "../src/harness"; +import l1 from "../levels/l1-double-tap.json"; +import l2 from "../levels/l2-spliced-corpse.json"; +import l3 from "../levels/l3-simultaneous-paradox.json"; + +type Placed = [string, number, number]; + +/** A level `width` wide and `height` high: text written as `[word, x, y]`, other objects, minion cards, a hand. */ +function level( + width: number, + height: number, + opts: { words?: Placed[]; objects?: Placed[]; cards?: Placed[]; hand?: string[] }, +): LevelDef { + return { + width, + height, + objects: [ + ...(opts.words ?? []).map(([word, x, y]) => ({ kind: `text:${word}`, x, y })), + ...(opts.objects ?? []).map(([kind, x, y]) => ({ kind, x, y })), + ], + cards: (opts.cards ?? []).map(([card, x, y]) => ({ card, x, y })), + hand: opts.hand ?? [], + }; +} + +/** The minion on tile (x, y); fails the test if there is none. */ +function minion(g: SpellGrid, x: number, y: number): Id { + const id = g.minionAt(x, y); + expect(id).not.toBeNull(); + return id!; +} + +/** How many times `source`'s Deathrattle triggered in the log. */ +const deathrattles = (g: SpellGrid, source: Id): number => + g.log().filter((e) => e.type === "Triggered" && e.source === source && e.trigger === "deathrattle").length; + +test("test_10_spatial_rule_feeds_interceptor", () => { + const g = newSpellGrid( + level(6, 3, { + words: [ + ["DEATHRATTLE", 0, 0], + ["IS", 1, 0], + ], + cards: [ + ["Powder Keg", 0, 2], + ["Bramble Wall", 2, 2], + ], + hand: ["Spark"], + }), + [], + ); + const keg = minion(g, 0, 2); + const bramble = minion(g, 2, 2); + + expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE"); + + g.place("text:DOUBLE", 2, 0); + + expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE"); + + g.clearLog(); + g.cast("Spark", keg); + + expect(deathrattles(g, keg)).toBe(2); + expect(g.health(bramble)).toBe(1); + expect(g.posOf(keg)).toBeNull(); + expect(g.posOf(bramble)).toEqual([2, 2]); + expect(g.hand()).toEqual([]); +}); + +test("test_10_not_cancels_spatial_modifier", () => { + const g = newSpellGrid( + level(6, 4, { + words: [ + ["DEATHRATTLE", 0, 0], + ["IS", 1, 0], + ["DOUBLE", 2, 0], + ["DEATHRATTLE", 0, 1], + ["IS", 1, 1], + ["NOT", 2, 1], + ["DOUBLE", 3, 1], + ], + cards: [ + ["Grave Warden", 0, 3], + ["Powder Keg", 1, 3], + ["Bramble Wall", 2, 3], + ], + hand: ["Spark"], + }), + [], + ); + const warden = minion(g, 0, 3); + const keg = minion(g, 1, 3); + const bramble = minion(g, 2, 3); + + expect(g.rules()).toContain("DEATHRATTLE IS DOUBLE"); + expect(g.rules()).toContain("DEATHRATTLE IS NOT DOUBLE"); + expect(g.activeRules()).toContain("DEATHRATTLE IS NOT DOUBLE"); + expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE"); + + g.clearLog(); + g.cast("Spark", keg); + + expect(deathrattles(g, keg)).toBe(1); + expect(g.health(bramble)).toBe(2); + expect(g.posOf(warden)).toBeNull(); +}); + +test("test_10_grid_and_card_doublers_take_max", () => { + const g = newSpellGrid( + level(6, 3, { + words: [ + ["DEATHRATTLE", 0, 0], + ["IS", 1, 0], + ["DOUBLE", 2, 0], + ], + cards: [ + ["Grave Warden", 0, 2], + ["Powder Keg", 1, 2], + ["Bramble Wall", 2, 2], + ], + hand: ["Spark"], + }), + [], + ); + const keg = minion(g, 1, 2); + const bramble = minion(g, 2, 2); + + g.clearLog(); + g.cast("Spark", keg); + + expect(deathrattles(g, keg)).toBe(2); + expect(g.health(bramble)).toBe(1); +}); + +test("test_10_hazard_feeds_death_step", () => { + const g = newSpellGrid( + level(5, 4, { + words: [ + ["KEG", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ["AND", 3, 0], + ["MELT", 4, 0], + ["LAVA", 0, 1], + ["IS", 1, 1], + ["HOT", 2, 1], + ], + objects: [["lava", 2, 3]], + cards: [ + ["Powder Keg", 1, 3], + ["Bramble Wall", 4, 3], + ], + }), + [], + ); + const keg = minion(g, 1, 3); + const bramble = minion(g, 4, 3); + + g.clearLog(); + g.step("right"); + + expect(g.log()).toEqual([ + { type: "Destroyed", id: keg }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: keg, trigger: "deathrattle" }, + { type: "Damaged", target: bramble, amount: 1 }, + ]); + expect(g.posOf(keg)).toBeNull(); + expect(g.kindsAt(2, 3)).toEqual(["lava"]); + expect(g.health(bramble)).toBe(2); + expect(g.lost()).toBe(true); +}); + +test("test_10_spawned_text_triggers_reparse", () => { + const g = newSpellGrid( + level(5, 3, { + words: [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ["BABA", 0, 1], + ["IS", 1, 1], + ], + cards: [["Rune Scribe", 2, 1]], + hand: ["Spark"], + }), + [], + ); + const scribe = minion(g, 2, 1); + const baba = g.place("baba", 4, 2); + + expect(g.rules()).not.toContain("BABA IS WIN"); + + g.cast("Spark", scribe); + + expect(g.kindsAt(2, 1)).toEqual(["text:WIN"]); + expect(g.rules()).toContain("BABA IS WIN"); + expect(g.hasProperty(baba, "WIN")).toBe(true); + expect(g.won()).toBe(false); + + g.step("left"); + + expect(g.won()).toBe(true); +}); + +test("test_10_level_1_solution", () => { + const g = newSpellGrid(l1 as LevelDef, []); + const keg = minion(g, 0, 4); + const bramble = minion(g, 5, 6); + + g.step("up"); + g.step("up"); + + expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE"); + + g.cast("Whirlwind"); + + expect(deathrattles(g, keg)).toBe(2); + expect(g.posOf(bramble)).toBeNull(); + + for (const dir of ["down", "down", "left", "down", "down", "right"] as const) g.step(dir); + + expect(g.won()).toBe(false); + expect(g.kindsAt(5, 6)).toEqual(["baba"]); + + g.step("right"); + + expect(g.won()).toBe(true); +}); + +test("test_10_level_2_solution", () => { + const g = newSpellGrid(l2 as LevelDef, []); + g.setChooser((kind) => (kind === "base" ? "Ember Moth" : kind === "donor" ? "Rune Scribe" : "bottom")); + + g.cast("Graft"); + + expect(g.hand()).toEqual(["Ember Moth (Assembled)"]); + + const moth = g.play("Ember Moth (Assembled)", 4, 3); + + expect(g.kindsAt(4, 3)).toEqual(["moth"]); + expect(g.nameOf(moth)).toBe("Ember Moth (Assembled)"); + + g.step("left"); + g.step("left"); + + expect(g.posOf(moth)).toBeNull(); + expect(g.kindsAt(2, 3)).toEqual(["lava", "text:WIN"]); + expect(g.rules()).toContain("BABA IS WIN"); + expect(g.won()).toBe(true); +}); + +/** Level 3's first input, one Whirlwind, after clearing the log. Returns the knight, keg, warden and hedge. */ +function castLevel3Whirlwind(g: SpellGrid): { knight: Id; keg: Id; warden: Id; hedge: Id } { + const ids = { knight: minion(g, 0, 4), keg: minion(g, 1, 5), warden: minion(g, 2, 5), hedge: minion(g, 4, 4) }; + g.clearLog(); + g.cast("Whirlwind"); + return ids; +} + +test("test_10_level_3_solution", () => { + const g = newSpellGrid(l3 as LevelDef, []); + const { knight, keg, warden, hedge } = castLevel3Whirlwind(g); + + expect(deathrattles(g, keg)).toBe(1); + expect(g.health(knight)).toBe(1); + expect(g.posOf(hedge)).toBeNull(); + expect(g.posOf(keg)).toBeNull(); + expect(g.posOf(warden)).toBeNull(); + + for (let i = 0; i < 5; i++) g.step("right"); + + expect(g.won()).toBe(true); +}); + +test("test_10_level_3_naive_would_differ", () => { + const g = newSpellGrid(l3 as LevelDef, []); + g.withNaiveAuraTiming(); + const { knight, keg } = castLevel3Whirlwind(g); + for (let i = 0; i < 5; i++) g.step("right"); + + expect(deathrattles(g, keg)).toBe(2); + expect(g.posOf(knight)).toBeNull(); + expect(g.lost()).toBe(true); + expect(g.won()).toBe(false); +}); diff --git a/course/cards/bramble-wall.json b/course/cards/bramble-wall.json new file mode 100644 index 0000000..b7007ed --- /dev/null +++ b/course/cards/bramble-wall.json @@ -0,0 +1,11 @@ +{ + "name": "Bramble Wall", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 3, + "noun": "BRAMBLE", + "split": 0, + "text": [], + "game": "spellgrid" +} diff --git a/course/cards/ember-moth.json b/course/cards/ember-moth.json new file mode 100644 index 0000000..4328645 --- /dev/null +++ b/course/cards/ember-moth.json @@ -0,0 +1,28 @@ +{ + "name": "Ember Moth", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "noun": "MOTH", + "split": 1, + "text": [ + { + "kind": "keyword", + "keyword": "Taunt" + }, + { + "kind": "trigger", + "on": "deathrattle" + }, + { + "kind": "effect", + "effect": { + "type": "dealDamage", + "target": "enemyHero", + "amount": 2 + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/graft.json b/course/cards/graft.json new file mode 100644 index 0000000..e9d6732 --- /dev/null +++ b/course/cards/graft.json @@ -0,0 +1,18 @@ +{ + "name": "Graft", + "type": "spell", + "cost": 2, + "split": 0, + "text": [ + { + "kind": "effect", + "effect": { + "type": "assemble", + "filter": { + "trigger": "deathrattle" + } + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/grave-warden.json b/course/cards/grave-warden.json new file mode 100644 index 0000000..a0bb6e8 --- /dev/null +++ b/course/cards/grave-warden.json @@ -0,0 +1,21 @@ +{ + "name": "Grave Warden", + "type": "minion", + "cost": 3, + "attack": 1, + "health": 1, + "noun": "WARDEN", + "split": 0, + "text": [ + { + "kind": "aura", + "modifier": { + "type": "triggerMultiplier", + "trigger": "deathrattle", + "times": 2, + "stacking": "max" + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/powder-keg.json b/course/cards/powder-keg.json new file mode 100644 index 0000000..2472d01 --- /dev/null +++ b/course/cards/powder-keg.json @@ -0,0 +1,24 @@ +{ + "name": "Powder Keg", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 1, + "noun": "KEG", + "split": 0, + "text": [ + { + "kind": "trigger", + "on": "deathrattle" + }, + { + "kind": "effect", + "effect": { + "type": "dealDamage", + "target": "allMinions", + "amount": 1 + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/rune-scribe.json b/course/cards/rune-scribe.json new file mode 100644 index 0000000..df0e3e2 --- /dev/null +++ b/course/cards/rune-scribe.json @@ -0,0 +1,24 @@ +{ + "name": "Rune Scribe", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 1, + "noun": "SCRIBE", + "split": 0, + "text": [ + { + "kind": "trigger", + "on": "deathrattle" + }, + { + "kind": "effect", + "effect": { + "type": "spawnText", + "word": "WIN", + "at": "self" + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/spark.json b/course/cards/spark.json new file mode 100644 index 0000000..516a44c --- /dev/null +++ b/course/cards/spark.json @@ -0,0 +1,17 @@ +{ + "name": "Spark", + "type": "spell", + "cost": 1, + "split": 0, + "text": [ + { + "kind": "effect", + "effect": { + "type": "dealDamage", + "target": "chosen", + "amount": 1 + } + } + ], + "game": "spellgrid" +} diff --git a/course/cards/thorn-hedge.json b/course/cards/thorn-hedge.json new file mode 100644 index 0000000..e05e79c --- /dev/null +++ b/course/cards/thorn-hedge.json @@ -0,0 +1,11 @@ +{ + "name": "Thorn Hedge", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 2, + "noun": "HEDGE", + "split": 0, + "text": [], + "game": "spellgrid" +} diff --git a/course/cards/tin-knight.json b/course/cards/tin-knight.json new file mode 100644 index 0000000..0675d57 --- /dev/null +++ b/course/cards/tin-knight.json @@ -0,0 +1,11 @@ +{ + "name": "Tin Knight", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 3, + "noun": "KNIGHT", + "split": 0, + "text": [], + "game": "spellgrid" +} diff --git a/course/cards/town-crier.json b/course/cards/town-crier.json new file mode 100644 index 0000000..5af7c18 --- /dev/null +++ b/course/cards/town-crier.json @@ -0,0 +1,24 @@ +{ + "name": "Town Crier", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "noun": "CRIER", + "split": 0, + "text": [ + { + "kind": "trigger", + "on": "battlecry" + }, + { + "kind": "effect", + "effect": { + "type": "dealDamage", + "target": "enemyHero", + "amount": 1 + } + } + ], + "game": "spellgrid" +} diff --git a/course/catalog.json b/course/catalog.json index 8290359..675666c 100644 --- a/course/catalog.json +++ b/course/catalog.json @@ -412,5 +412,157 @@ "text": "a trigger type, so DEATHRATTLE IS DOUBLE doubles deathrattles", "source": "https://github.com/utilForever/baba-is-auto/blob/main/Documents/rule-language.md", "game": "spellgrid" + }, + { + "name": "Powder Keg", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 1, + "text": "Deathrattle: Deal 1 damage to all minions.", + "game": "spellgrid" + }, + { + "name": "Bramble Wall", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 3, + "text": "", + "game": "spellgrid" + }, + { + "name": "Thorn Hedge", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 2, + "text": "", + "game": "spellgrid" + }, + { + "name": "Grave Warden", + "kind": "card", + "type": "minion", + "cost": 3, + "attack": 1, + "health": 1, + "text": "Your minions trigger their Deathrattles twice. (SpellGrid original: a Baron-like card with 1 Health.)", + "game": "spellgrid" + }, + { + "name": "Ember Moth", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "text": "Taunt. Deathrattle: Deal 2 damage to the enemy hero.", + "game": "spellgrid" + }, + { + "name": "Rune Scribe", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 1, + "text": "Deathrattle: Write WIN on this minion's tile.", + "game": "spellgrid" + }, + { + "name": "Tin Knight", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 1, + "health": 3, + "text": "", + "game": "spellgrid" + }, + { + "name": "Spark", + "kind": "card", + "type": "spell", + "cost": 1, + "text": "Deal 1 damage to a minion.", + "game": "spellgrid" + }, + { + "name": "Graft", + "kind": "card", + "type": "spell", + "cost": 2, + "text": "Assemble a Deathrattle minion.", + "game": "spellgrid" + }, + { + "name": "KEG", + "kind": "word", + "role": "noun", + "text": "Powder Keg's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "BRAMBLE", + "kind": "word", + "role": "noun", + "text": "Bramble Wall's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "HEDGE", + "kind": "word", + "role": "noun", + "text": "Thorn Hedge's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "WARDEN", + "kind": "word", + "role": "noun", + "text": "Grave Warden's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "MOTH", + "kind": "word", + "role": "noun", + "text": "Ember Moth's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "SCRIBE", + "kind": "word", + "role": "noun", + "text": "Rune Scribe's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "KNIGHT", + "kind": "word", + "role": "noun", + "text": "Tin Knight's noun (a SpellGrid minion on the grid)", + "game": "spellgrid" + }, + { + "name": "Town Crier", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 1, + "health": 1, + "text": "Battlecry: Deal 1 damage to the enemy hero.", + "game": "spellgrid" + }, + { + "name": "BATTLECRY", + "kind": "word", + "role": "meta", + "text": "a trigger type, so BATTLECRY IS DOUBLE doubles battlecries (the Module 10 Expansion Drop)", + "game": "spellgrid" } ] diff --git a/course/harness.md b/course/harness.md index bea500c..aa1d79b 100644 --- a/course/harness.md +++ b/course/harness.md @@ -131,6 +131,25 @@ From M9, `step(dir)` plays one whole turn, in the course's simplified order: mov ## 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. +- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast(card, target?)`, `play(card, x, y): Id`, `health(id)`, `hero(player)`, `nameOf(id)`, `minionAt(x, y): Id | null`, `hand(): string[]`, `log()`, `clearLog()`, `setChooser(choose)`, `won()`, and the test hook `withNaiveAuraTiming()`. Minions are grid objects whose noun is their card's `"noun"` field. Rust: `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid`. + +| TypeScript | Rust (`impl SpellGrid`) | +|---|---| +| `newSpellGrid(level, cards)` | `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid` (free function) | +| Part II: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost` | the same as on `Baba` (`move_dir` for `move`) | +| `cast(card, target?)` | `cast(&mut self, card: &str, target: Option)` | +| `play(card, x, y): Id` | `play(&mut self, card: &str, x: i32, y: i32) -> Id` | +| `health(id)`, `hero(player)`, `nameOf(id)` | `health(&self, id: Id) -> i32`, `hero(&self, player: Player) -> Id`, `name_of(&self, id: Id) -> String` | +| `minionAt(x, y): Id \| null` | `minion_at(&self, x: i32, y: i32) -> Option` | +| `hand(): string[]` | `hand(&self) -> Vec` | +| `log()`, `clearLog()` | `log(&self) -> Vec`, `clear_log(&mut self)` | +| `setChooser(choose)` | `set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)` | +| `withNaiveAuraTiming()` | `with_naive_aura_timing(&mut self)` | + +From M10, `LevelDef` has two more optional fields, `cards: { card, x, y }[]` and `hand: string[]` (Rust: `cards: Vec` with `LevelCard { card: String, x: i32, y: i32 }`, and `hand: Vec`, both `#[serde(default)]`). Each entry of `cards` is a minion in play for player 0 and a grid object at (x, y) whose kind is the card's noun in lower case. `hand` is player 0's hand. The constructor's `cards` argument is extra card definitions, added to the workspace's `cards/` (the course's tests pass none). + +SpellGrid has one id space: a minion's id is its grid object's id, so `posOf`, `hasProperty` and `health` take the same id, and `log()` reports it. A hero or a card in hand gets an id that no grid object has; a card played from hand gets its grid object's id (returned by `play`). `cast(card, target?)` casts a spell from player 0's hand as one Sequence (target: a minion's id); `play(card, x, y)` plays a minion from player 0's hand onto an empty tile as one Sequence (it enters the board, then its Battlecry). Both are errors for a card not in the hand, and `play` for a tile that holds anything. Casting is not a turn: `won()` changes only in `step`. + +The three seams: (1) a rule in force ` IS ` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and ` IS NOT ` caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, `OPEN`/`SHUT`, `X IS NOT X`) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (`spawnText`) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. `withNaiveAuraTiming()` reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only `test_10_level_3_naive_would_differ` uses it. diff --git a/course/schema/level.schema.json b/course/schema/level.schema.json index 8bbf50f..eb8cd86 100644 --- a/course/schema/level.schema.json +++ b/course/schema/level.schema.json @@ -1,5 +1,5 @@ { - "$comment": "x grows right, y grows down.", + "$comment": "x grows right, y grows down. Module 10 (SpellGrid): cards are minions in play for player 0, each also a grid object whose noun is the card's noun; hand is the card names in player 0's hand.", "type": "object", "required": ["width", "height", "objects"], "properties": { @@ -8,6 +8,7 @@ "objects": { "type": "array", "items": { "type": "object", "required": ["kind", "x", "y"], "properties": { "kind": { "type": "string" }, "x": { "type": "integer", "minimum": 0 }, "y": { "type": "integer", "minimum": 0 } } } }, "cards": { "type": "array", "items": { "type": "object", "required": ["card", "x", "y"], - "properties": { "card": { "type": "string" }, "x": { "type": "integer", "minimum": 0 }, "y": { "type": "integer", "minimum": 0 } } } } + "properties": { "card": { "type": "string" }, "x": { "type": "integer", "minimum": 0 }, "y": { "type": "integer", "minimum": 0 } } } }, + "hand": { "type": "array", "items": { "type": "string" } } } } diff --git a/tools/start-module.mjs b/tools/start-module.mjs index 29ceef8..5842725 100644 --- a/tools/start-module.mjs +++ b/tools/start-module.mjs @@ -40,6 +40,13 @@ if (existsSync(cards)) { console.log(`Copied the course card data into ${join(ws, "cards")}`); } +// The module's puzzle levels (Module 10's tests load them from levels/). +const levels = join(mod.path, "levels"); +if (existsSync(levels)) { + cpSync(levels, join(ws, "levels"), { recursive: true }); + console.log(`Copied the module's levels into ${join(ws, "levels")}`); +} + // Harness additions: module-local note, else the "- M:" line of course/harness.md. const note = join(mod.path, "harness-adds.md"); let adds = existsSync(note) ? readFileSync(note, "utf8").trim() : ""; diff --git a/tools/test/fixtures/course/03-m03/levels/fixture-level.json b/tools/test/fixtures/course/03-m03/levels/fixture-level.json new file mode 100644 index 0000000..0536fab --- /dev/null +++ b/tools/test/fixtures/course/03-m03/levels/fixture-level.json @@ -0,0 +1 @@ +{ "width": 1, "height": 1, "objects": [] } diff --git a/tools/test/schema.test.mjs b/tools/test/schema.test.mjs index 17b72f6..77c8708 100644 --- a/tools/test/schema.test.mjs +++ b/tools/test/schema.test.mjs @@ -27,11 +27,14 @@ test("catalog_valid", () => { assert.ok(Array.isArray(cat) && cat.length > 0); const names = new Set(); for (const e of cat) { - assert.deepEqual(validate({ ...entry, required: e.fictional ? ["name", "kind", "text"] : entry.required }, e), [], e.name); + // Test cards and SpellGrid's own cards and words (Module 10) have no outside source. + const sourced = !e.fictional && e.game !== "spellgrid"; + assert.deepEqual(validate({ ...entry, required: sourced ? entry.required : ["name", "kind", "text"] }, e), [], e.name); assert.deepEqual(validate(e.kind === "card" ? cardEntry : wordEntry, e), [], e.name); assert.ok(!names.has(e.name), `duplicate ${e.name}`); names.add(e.name); - if (e.kind === "card" && !e.fictional) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name); + if (e.kind === "card" && sourced) assert.match(e.source, /^https:\/\/hearthstone\.wiki\.gg\//, e.name); + if (e.game === "spellgrid") assert.ok(!e.fictional && !e.name.startsWith("Test "), `${e.name}: a SpellGrid card is not a test card`); if (e.kind === "card" && e.name.startsWith("Test ")) assert.equal(e.fictional, true, e.name); if (e.fictional) assert.ok(e.name.startsWith("Test "), e.name); } @@ -84,8 +87,8 @@ test("course_cards_valid_and_match_catalog", () => { ); assert.ok(c.split <= c.text.length, `${f}: split within text`); } - // Every Hearthstone (and fictional) card in the catalog has its data. - for (const e of cat.values()) if (e.kind === "card" && !e.game) assert.ok(seen.has(e.name), `no card file for ${e.name}`); + // Every card in the catalog (Hearthstone, fictional and SpellGrid's own) has its data. + for (const e of cat.values()) if (e.kind === "card") assert.ok(seen.has(e.name), `no card file for ${e.name}`); }); test("card_schema_assemble_effect", () => { @@ -108,3 +111,33 @@ test("level_schema", () => { assert.deepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0, y: 0 }] }), []); assert.notDeepEqual(validate(levelSchema, { width: 3, height: 1, objects: [{ kind: "baba", x: 0 }] }), []); }); + +test("level_schema_spellgrid_fields", () => { + const level = { width: 2, height: 1, objects: [], cards: [{ card: "Powder Keg", x: 1, y: 0 }], hand: ["Spark"] }; + assert.deepEqual(validate(levelSchema, level), []); + assert.notDeepEqual(validate(levelSchema, { ...level, hand: [{ card: "Spark" }] }), []); + assert.notDeepEqual(validate(levelSchema, { ...level, cards: [{ card: "Powder Keg", x: 1 }] }), []); +}); + +// Puzzle levels the course ships: course/NN-*/levels/*.json. Every one is valid, stays +// on its grid, and names only cards that have data, minions with a noun on the grid. +test("course_levels_valid", () => { + const courseDir = join(here, "../../course"); + const cards = new Map(courseCardFiles().map((f) => json(f)).map((c) => [c.name, c])); + const files = readdirSync(courseDir) + .filter((d) => /^\d\d-/.test(d) && existsSync(join(courseDir, d, "levels"))) + .flatMap((d) => readdirSync(join(courseDir, d, "levels")).filter((f) => f.endsWith(".json")).map((f) => join(courseDir, d, "levels", f))); + assert.ok(files.length >= 3, `found ${files.length} level files`); + for (const f of files) { + const level = json(f); + assert.deepEqual(validate(levelSchema, level), [], f); + for (const o of [...level.objects, ...(level.cards ?? [])]) { + assert.ok(o.x < level.width && o.y < level.height, `${f}: (${o.x}, ${o.y}) is off the grid`); + } + for (const { card } of level.cards ?? []) { + const c = cards.get(card); + assert.ok(c && c.type === "minion" && c.noun, `${f}: ${card} is a minion with a noun`); + } + for (const card of level.hand ?? []) assert.ok(cards.has(card), `${f}: no card data for ${card}`); + } +}); diff --git a/tools/test/start-module.test.mjs b/tools/test/start-module.test.mjs index 29fa2eb..f2bfe6e 100644 --- a/tools/test/start-module.test.mjs +++ b/tools/test/start-module.test.mjs @@ -21,6 +21,15 @@ test("start_module_copies_course_cards", () => { assert.ok(existsSync(join(ws, "cards/test-fixture.json"))); }); +test("start_module_copies_module_levels", () => { + const ws = copyFixture("ws-start"); + mkdirSync(join(ws, "tests"), { recursive: true }); + writeFileSync(join(ws, "tests/m02.rs"), ""); + const r = run("start-module.mjs", ["03", ws], env); + assert.equal(r.code, 0, r.out); + assert.ok(existsSync(join(ws, "levels/fixture-level.json"))); +}); + test("start_module_uses_track", () => { const ws = copyFixture("ws-start-ts"); assert.equal(run("start-module.mjs", ["01", ws], env).code, 0); diff --git a/tools/test/verify-references.test.mjs b/tools/test/verify-references.test.mjs index fcd3146..b4fb2d9 100644 --- a/tools/test/verify-references.test.mjs +++ b/tools/test/verify-references.test.mjs @@ -66,3 +66,13 @@ test("verify_ts_default_installs_dependencies_first", async () => { assert.equal(commandFor("ts", { VERIFY_CMD_TS: "true" }), "true"); assert.equal(commandFor("rust", {}), "cargo test"); }); + +test("verify_stage_copies_module_levels", async () => { + // From Module 10 the tests load puzzle levels from /levels. + process.env.COURSE_ROOT = root; + const { planVerification, stage } = await import("../verify-references.mjs"); + const { existsSync } = await import("node:fs"); + const plans = planVerification(moduleDirs(), 3); + assert.ok(existsSync(join(stage(plans[2], "ts"), "levels", "fixture-level.json"))); + assert.ok(!existsSync(join(stage(plans[1], "ts"), "levels"))); +}); diff --git a/tools/verify-references.mjs b/tools/verify-references.mjs index 2a724fd..b02e46a 100644 --- a/tools/verify-references.mjs +++ b/tools/verify-references.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests. -// course/cards (the card data, loaded by the references from Module 5) is staged as /cards. +// course/cards (the card data, loaded by the references from Module 5) is staged as /cards, +// and each tested module's levels/ (the puzzle levels its tests load, from Module 10) as /levels. import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -35,6 +36,7 @@ export function stage(plan, lang) { mkdirSync(join(dir, "tests"), { recursive: true }); for (const t of plan.testModules) { for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f))); + if (existsSync(join(t.path, "levels"))) cpSync(join(t.path, "levels"), join(dir, "levels"), { recursive: true }); } const cards = join(courseRoot(), "cards"); if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });