From fb9d0a4eb437a563a5c4bb4d465f30b03e0a032b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 11 Nov 2026 13:34:20 +0100 Subject: [PATCH] feat: Module 12, Combat (attack as one Sequence, Taunt, defender-first as a queue priority, end of turn; Bonechewer Brawler as the Expansion Drop) Adds course/12-combat: lesson, scenarios, nine test_12 tests in both tracks, the cumulative reference (Module 11's plus combat) and the drop. The Damage action carries per-target hits with a priority; react stable-sorts its Queue by priority before order of play, so the triggers answering the defender's Damage Event go first. orderAttack/canAttack/endTurn join the harness (orderAttack because attack(id) already reads Attack). endOfTurn triggers fire, so Drakkari Enchanter's data is live. Test Bell is scoped to Module 12's cards. Harness contract, catalog, research notes and earlier lessons' forward pointers follow. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- course/01-handles/lesson.md | 2 +- course/02-action-queue/lesson.md | 4 +- course/03-death-step/lesson.md | 2 +- course/05-assemble/lesson.md | 2 +- course/12-combat/cards/test-bell.json | 23 + course/12-combat/expansion/README.md | 22 + .../solution/cards/bonechewer-brawler.json | 26 + .../expansion/tests/rust/test_12x.rs | 47 + .../expansion/tests/ts/test_12x.test.ts | 27 + course/12-combat/lesson.md | 447 ++++ course/12-combat/reference/rust/.course.json | 1 + course/12-combat/reference/rust/.gitignore | 1 + course/12-combat/reference/rust/Cargo.lock | 107 + course/12-combat/reference/rust/Cargo.toml | 9 + course/12-combat/reference/rust/README.md | 14 + .../reference/rust/src/bin/spellgrid.rs | 21 + course/12-combat/reference/rust/src/cli.rs | 200 ++ .../reference/rust/src/content/conditions.rs | 54 + .../reference/rust/src/content/hazards.rs | 54 + .../reference/rust/src/content/mod.rs | 63 + .../reference/rust/src/content/movement.rs | 44 + .../reference/rust/src/content/seams.json | 4 + .../reference/rust/src/content/words.json | 36 + .../reference/rust/src/engine/assemble.rs | 174 ++ .../reference/rust/src/engine/aura.rs | 84 + .../reference/rust/src/engine/card.rs | 348 +++ .../reference/rust/src/engine/combat.rs | 49 + .../reference/rust/src/engine/death.rs | 41 + .../reference/rust/src/engine/effect.rs | 97 + .../reference/rust/src/engine/event.rs | 76 + .../reference/rust/src/engine/mod.rs | 68 + .../rust/src/engine/puzzle/condition.rs | 21 + .../reference/rust/src/engine/puzzle/grid.rs | 175 ++ .../reference/rust/src/engine/puzzle/level.rs | 382 ++++ .../reference/rust/src/engine/puzzle/mod.rs | 47 + .../rust/src/engine/puzzle/movement.rs | 144 ++ .../reference/rust/src/engine/puzzle/parse.rs | 313 +++ .../rust/src/engine/puzzle/resolve.rs | 39 + .../reference/rust/src/engine/puzzle/turn.rs | 44 + .../reference/rust/src/engine/puzzle/word.rs | 135 ++ .../reference/rust/src/engine/queue.rs | 480 ++++ .../reference/rust/src/engine/spellgrid.rs | 418 ++++ .../reference/rust/src/engine/world.rs | 456 ++++ .../12-combat/reference/rust/src/harness.rs | 555 +++++ course/12-combat/reference/rust/src/lib.rs | 4 + course/12-combat/reference/rust/tests/cli.rs | 113 + course/12-combat/reference/ts/.course.json | 1 + course/12-combat/reference/ts/.gitignore | 1 + course/12-combat/reference/ts/README.md | 14 + .../12-combat/reference/ts/package-lock.json | 2018 +++++++++++++++++ course/12-combat/reference/ts/package.json | 16 + course/12-combat/reference/ts/src/cli.ts | 138 ++ .../reference/ts/src/content/conditions.ts | 47 + .../reference/ts/src/content/hazards.ts | 48 + .../reference/ts/src/content/index.ts | 50 + .../reference/ts/src/content/movement.ts | 45 + .../reference/ts/src/content/seams.json | 4 + .../reference/ts/src/content/words.json | 36 + .../reference/ts/src/engine/assemble.ts | 117 + .../12-combat/reference/ts/src/engine/aura.ts | 65 + .../12-combat/reference/ts/src/engine/card.ts | 212 ++ .../reference/ts/src/engine/combat.ts | 36 + .../reference/ts/src/engine/death.ts | 29 + .../reference/ts/src/engine/effect.ts | 76 + .../reference/ts/src/engine/event.ts | 40 + .../reference/ts/src/engine/index.ts | 78 + .../ts/src/engine/puzzle/condition.ts | 21 + .../reference/ts/src/engine/puzzle/grid.ts | 145 ++ .../reference/ts/src/engine/puzzle/index.ts | 47 + .../reference/ts/src/engine/puzzle/level.ts | 316 +++ .../ts/src/engine/puzzle/movement.ts | 130 ++ .../reference/ts/src/engine/puzzle/parse.ts | 227 ++ .../reference/ts/src/engine/puzzle/resolve.ts | 29 + .../reference/ts/src/engine/puzzle/turn.ts | 33 + .../reference/ts/src/engine/puzzle/word.ts | 110 + .../reference/ts/src/engine/queue.ts | 396 ++++ .../reference/ts/src/engine/spellgrid.ts | 282 +++ .../reference/ts/src/engine/world.ts | 382 ++++ course/12-combat/reference/ts/src/harness.ts | 313 +++ .../12-combat/reference/ts/tests/cli.test.ts | 70 + course/12-combat/reference/ts/tsconfig.json | 13 + .../12-combat/reference/ts/vitest.config.ts | 5 + course/12-combat/scenarios.md | 132 ++ course/12-combat/tests/rust/test_12.rs | 313 +++ course/12-combat/tests/ts/test_12.test.ts | 255 +++ course/catalog.json | 20 + course/harness.md | 6 + docs/research/hearthstone.md | 67 + 89 files changed, 11852 insertions(+), 6 deletions(-) create mode 100644 course/12-combat/cards/test-bell.json create mode 100644 course/12-combat/expansion/README.md create mode 100644 course/12-combat/expansion/solution/cards/bonechewer-brawler.json create mode 100644 course/12-combat/expansion/tests/rust/test_12x.rs create mode 100644 course/12-combat/expansion/tests/ts/test_12x.test.ts create mode 100644 course/12-combat/lesson.md create mode 100644 course/12-combat/reference/rust/.course.json create mode 100644 course/12-combat/reference/rust/.gitignore create mode 100644 course/12-combat/reference/rust/Cargo.lock create mode 100644 course/12-combat/reference/rust/Cargo.toml create mode 100644 course/12-combat/reference/rust/README.md create mode 100644 course/12-combat/reference/rust/src/bin/spellgrid.rs create mode 100644 course/12-combat/reference/rust/src/cli.rs create mode 100644 course/12-combat/reference/rust/src/content/conditions.rs create mode 100644 course/12-combat/reference/rust/src/content/hazards.rs create mode 100644 course/12-combat/reference/rust/src/content/mod.rs create mode 100644 course/12-combat/reference/rust/src/content/movement.rs create mode 100644 course/12-combat/reference/rust/src/content/seams.json create mode 100644 course/12-combat/reference/rust/src/content/words.json create mode 100644 course/12-combat/reference/rust/src/engine/assemble.rs create mode 100644 course/12-combat/reference/rust/src/engine/aura.rs create mode 100644 course/12-combat/reference/rust/src/engine/card.rs create mode 100644 course/12-combat/reference/rust/src/engine/combat.rs create mode 100644 course/12-combat/reference/rust/src/engine/death.rs create mode 100644 course/12-combat/reference/rust/src/engine/effect.rs create mode 100644 course/12-combat/reference/rust/src/engine/event.rs create mode 100644 course/12-combat/reference/rust/src/engine/mod.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/condition.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/grid.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/level.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/mod.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/movement.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/parse.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/resolve.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/turn.rs create mode 100644 course/12-combat/reference/rust/src/engine/puzzle/word.rs create mode 100644 course/12-combat/reference/rust/src/engine/queue.rs create mode 100644 course/12-combat/reference/rust/src/engine/spellgrid.rs create mode 100644 course/12-combat/reference/rust/src/engine/world.rs create mode 100644 course/12-combat/reference/rust/src/harness.rs create mode 100644 course/12-combat/reference/rust/src/lib.rs create mode 100644 course/12-combat/reference/rust/tests/cli.rs create mode 100644 course/12-combat/reference/ts/.course.json create mode 100644 course/12-combat/reference/ts/.gitignore create mode 100644 course/12-combat/reference/ts/README.md create mode 100644 course/12-combat/reference/ts/package-lock.json create mode 100644 course/12-combat/reference/ts/package.json create mode 100644 course/12-combat/reference/ts/src/cli.ts create mode 100644 course/12-combat/reference/ts/src/content/conditions.ts create mode 100644 course/12-combat/reference/ts/src/content/hazards.ts create mode 100644 course/12-combat/reference/ts/src/content/index.ts create mode 100644 course/12-combat/reference/ts/src/content/movement.ts create mode 100644 course/12-combat/reference/ts/src/content/seams.json create mode 100644 course/12-combat/reference/ts/src/content/words.json create mode 100644 course/12-combat/reference/ts/src/engine/assemble.ts create mode 100644 course/12-combat/reference/ts/src/engine/aura.ts create mode 100644 course/12-combat/reference/ts/src/engine/card.ts create mode 100644 course/12-combat/reference/ts/src/engine/combat.ts create mode 100644 course/12-combat/reference/ts/src/engine/death.ts create mode 100644 course/12-combat/reference/ts/src/engine/effect.ts create mode 100644 course/12-combat/reference/ts/src/engine/event.ts create mode 100644 course/12-combat/reference/ts/src/engine/index.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/condition.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/grid.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/index.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/level.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/movement.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/parse.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/resolve.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/turn.ts create mode 100644 course/12-combat/reference/ts/src/engine/puzzle/word.ts create mode 100644 course/12-combat/reference/ts/src/engine/queue.ts create mode 100644 course/12-combat/reference/ts/src/engine/spellgrid.ts create mode 100644 course/12-combat/reference/ts/src/engine/world.ts create mode 100644 course/12-combat/reference/ts/src/harness.ts create mode 100644 course/12-combat/reference/ts/tests/cli.test.ts create mode 100644 course/12-combat/reference/ts/tsconfig.json create mode 100644 course/12-combat/reference/ts/vitest.config.ts create mode 100644 course/12-combat/scenarios.md create mode 100644 course/12-combat/tests/rust/test_12.rs create mode 100644 course/12-combat/tests/ts/test_12.test.ts diff --git a/README.md b/README.md index c36ae2c..69854d4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A free, hands-on course on **systemic game design**. You learn by rebuilding the rules machinery of two games, then extending it: -- **Part I: Hearthstone** (cards, zones, triggers, death phases, auras, text assembly) +- **Part I: Hearthstone** (cards, zones, triggers, death phases, auras, text assembly; Module 12 adds combat, Taunt and the end of a turn) - **Part II: Baba Is You** (rules as pushable text, parsing, movement, win/lose; Module 11 adds conditions, `BABA ON KEY IS WIN`) - **Part III: SpellGrid**, a small original game that combines both systems - **Module 13: Break your own engine**, an optional closing module on testing what the tests miss: mutation testing, invariants, metamorphic relations, undo and replay, and a level solver diff --git a/course/01-handles/lesson.md b/course/01-handles/lesson.md index 9dc53d6..bbd64b3 100644 --- a/course/01-handles/lesson.md +++ b/course/01-handles/lesson.md @@ -164,7 +164,7 @@ addToHand(card, p) = create(card, p, hand) When your tests pass, compare with the reference engine in `course/01-handles/reference/rust/src/engine/` or `course/01-handles/reference/ts/src/engine/`. The reference engine names no cards; its content library is one line defining `Test Filler`. > **How the real game differs** -> The course stamps order of play only when a minion enters the board. The rulebook's order of play is about Entities entering play in general, and it has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and in combat "the defender ALWAYS triggers first, regardless of order of play" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). None of these are modelled in this course. The course also has no heroes yet (Module 2 adds them), and it puts every summoned or played minion at the right end of the board instead of letting a player choose a position. +> The course stamps order of play only when a minion enters the board. The rulebook's order of play is about Entities entering play in general, and it has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and in combat "the defender ALWAYS triggers first, regardless of order of play" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course models the combat rule in [Module 12](../12-combat/lesson.md), as a priority on queue entries; the other two are not modelled. The course also has no heroes yet (Module 2 adds them), and it puts every summoned or played minion at the right end of the board instead of letting a player choose a position. ## Name it diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md index b8a2e63..5b6d1fb 100644 --- a/course/02-action-queue/lesson.md +++ b/course/02-action-queue/lesson.md @@ -216,7 +216,7 @@ With this queue order, Whirlwind on Armorsmith and Acolyte gives Armorsmith (its When your tests pass, compare with the reference engine in `course/02-action-queue/reference/rust/src/engine/` or `course/02-action-queue/reference/ts/src/engine/`. The resolver is `queue.rs` / `queue.ts`; the content library is a list of card definitions, one short effect each. > **How the real game differs** -> The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). None of these are modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor (Module 4 makes Armor absorb damage: [A second interceptor: replacement](../04-interceptors/lesson.md#a-second-interceptor-replacement)); the game has no death step until Module 3. +> The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The combat rule arrives in [Module 12](../12-combat/lesson.md), as a priority on queue entries; the others are not modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor (Module 4 makes Armor absorb damage: [A second interceptor: replacement](../04-interceptors/lesson.md#a-second-interceptor-replacement)); the game has no death step until Module 3. ## Name it @@ -404,7 +404,7 @@ This module doesn't build it: no Module 2 test damages a hero that has Armor. [M ## Further reading -- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017). The most readable worked implementation of a Hearthstone-style reaction tree, in C#. Read `SortActions`: reactions are sorted by `priority` first and `orderOfPlay` only as the tie-breaker, which is where an engine would put Poisonous or Redemption. Also note the loop at the end, the "death reaper", which runs "to take care of any mortally wounded cards" after the root action. That is Module 3. +- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017). The most readable worked implementation of a Hearthstone-style reaction tree, in C#. Read `SortActions`: reactions are sorted by `priority` first and `orderOfPlay` only as the tie-breaker, which is where an engine would put Poisonous or Redemption. [Module 12](../12-combat/lesson.md) puts the combat defender's triggers there. Also note the loop at the end, the "death reaper", which runs "to take care of any mortally wounded cards" after the root action. That is Module 3. - Robert Nystrom, [*Event Queue*](https://gameprogrammingpatterns.com/event-queue.html) (*Game Programming Patterns*). The general pattern, and its warnings. Read the sections on feedback loops and on the world state having changed by the time an event is handled: "you have to be careful not to assume the current state of the world reflects how the world was". Your ids from Module 1 are part of the answer. - Alchyr, [*Action Queue*](https://github.com/Alchyr/BasicMod/wiki/Action-Queue) (BasicMod wiki, Slay the Spire modding). A short practical page about a different game's queue, where the modder chooses where a new action goes: "In a card's use: add to bottom. In an action's update: add to top." Compare it with the depth-first rule: "add to top" is how Slay the Spire makes a reaction happen before the next sibling. - [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): Rule 2 (Queues), the depth-first section, and Rule DH1 on area damage. diff --git a/course/03-death-step/lesson.md b/course/03-death-step/lesson.md index 32c0a74..2537c14 100644 --- a/course/03-death-step/lesson.md +++ b/course/03-death-step/lesson.md @@ -249,7 +249,7 @@ Three decisions in the reference are worth naming, because the rulebook raises t > - The rulebook says "negative (damaging/destroying/hindering/misdirecting) triggers ignore mortally wounded and pending destroy characters", while "AoE effects count mortally wounded" (same source). The course's area effects count them, which matches; nothing in the course is a targeted negative trigger, so the first rule is not modelled. > - A Death Phase queues "Deathrattles, on-Death Secrets and on-Death triggered effects" (same source). The course has only Deathrattles. > - "Feign Death and similar effects summon minions at the right side of the board, not the minion's position" (same source). The course has no such effect; every Deathrattle summon appears where its minion stood. -> - Unstable Ghoul has Taunt. The course has no combat, so Taunt does nothing here. Heroes never die in the course either: no game ends. +> - Unstable Ghoul has Taunt. The course has no combat until [Module 12](../12-combat/lesson.md), so Taunt does nothing here. Heroes never die in the course either: no game ends. ## Name it diff --git a/course/05-assemble/lesson.md b/course/05-assemble/lesson.md index b9a5970..ab4240b 100644 --- a/course/05-assemble/lesson.md +++ b/course/05-assemble/lesson.md @@ -307,7 +307,7 @@ Decisions in the reference that the tests don't force: - **The pool** is every registered minion with text, minus Assembled cards. That includes the fictional `Test` cards. The real pool is curated: see below. - **Two Assembles of the same base with different donors** would both be named "Harvest Golem (Assembled)", and registering the second would silently rewrite the first. The reference names the second "Harvest Golem (Assembled 2)". The harness contract fixes the first name, and `test_05_assemble_never_rewrites_an_earlier_card` fixes only that the second differs. "Different" means anything the engine reads: the text, the `split`, or a base re-registered with a new cost. The two reference engines once disagreed here, because one of them compared only the text. - **The default chooser** takes the first option. A real game would ask the player or roll; the tests never rely on the default. -- **Unknown keywords do nothing.** Unstable Ghoul's data says `taunt`; the course has no combat, so the engine keeps the clause (it is part of the text Assemble moves) and ignores it. +- **Unknown keywords do nothing.** Unstable Ghoul's data says `taunt`; the course has no combat until [Module 12](../12-combat/lesson.md), so the engine keeps the clause (it is part of the text Assemble moves) and ignores it. > **How the real game differs** > - The real Assemble puts a *random* minion in your hand and presents *two* other minions ([Assemble](https://hearthstone.wiki.gg/wiki/Assemble)). The course offers every eligible minion and lets the chooser pick each part. A random chooser that draws one base and two donors gives back the real game's shape; the engine doesn't change. diff --git a/course/12-combat/cards/test-bell.json b/course/12-combat/cards/test-bell.json new file mode 100644 index 0000000..afd422f --- /dev/null +++ b/course/12-combat/cards/test-bell.json @@ -0,0 +1,23 @@ +{ + "name": "Test Bell", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 3, + "split": 1, + "fictional": true, + "text": [ + { + "kind": "trigger", + "on": "endOfTurn" + }, + { + "kind": "effect", + "effect": { + "type": "dealDamage", + "target": "enemyHero", + "amount": 1 + } + } + ] +} diff --git a/course/12-combat/expansion/README.md b/course/12-combat/expansion/README.md new file mode 100644 index 0000000..5b30cb7 --- /dev/null +++ b/course/12-combat/expansion/README.md @@ -0,0 +1,22 @@ +# Module 12 Expansion Drop: Bonechewer Brawler + +The lesson's [Expansion Drop](../lesson.md#expansion-drop) beat explains the task. This file holds the scenario, the test locations and the check. + +- Card: Bonechewer Brawler, 2-mana 2/3 Neutral minion, "Taunt. Whenever this minion takes damage, gain +2 Attack." ([Bonechewer Brawler, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Bonechewer_Brawler)). +- Tests: `tests/rust/test_12x.rs` and `tests/ts/test_12x.test.ts`. Copy the one for your track into your workspace's `tests/`. +- Reference answer: `solution/cards/bonechewer-brawler.json`, one card file for both tracks. Copy it into your workspace's `cards/`. Nothing under `src/engine/` changes, and neither does the harness. It is not in `course/cards/`, so `start-module` doesn't hand it to you. +- 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. + +Every clause of the card is a word the course already has: the keyword `taunt` (Unstable Ghoul has it), the trigger `selfDamaged` (Acolyte of Pain) and the effect `gainAttack` on `self` (Frothing Berserker). Before this module the engine kept `taunt` and did nothing with it. Now combat reads it. + +## Scenario + +### test_12x_bonechewer_brawler + +- Setup: `newGame` with a deck of 5 `Test Filler` for each player. Summon `Bonechewer Brawler` for player 1 (id W), then `Loot Hoarder` for player 1 (id H), then `Harvest Golem` for player 0 (id A). Call `clearLog()`. +- Assertions 1: `canAttack(A, H)` is a string containing `Taunt`: the Brawler protects the Hoarder. +- Action: `orderAttack(A, W)`. +- Assertions 2: + - The log is exactly, in this order: `Damaged{target: W, amount: 2}`, `Damaged{target: A, amount: 2}`, `Triggered{source: W, trigger: "damaged"}`, `StatsChanged{id: W, attack: 4, health: 1}`. + - `health(A)` is 1: the Golem took 2, the Brawler's Attack when the damage was dealt. The +2 Attack comes from a trigger, after the simultaneous damage. + - `attack(W)` is 4. diff --git a/course/12-combat/expansion/solution/cards/bonechewer-brawler.json b/course/12-combat/expansion/solution/cards/bonechewer-brawler.json new file mode 100644 index 0000000..abdfd48 --- /dev/null +++ b/course/12-combat/expansion/solution/cards/bonechewer-brawler.json @@ -0,0 +1,26 @@ +{ + "name": "Bonechewer Brawler", + "type": "minion", + "cost": 2, + "attack": 2, + "health": 3, + "split": 1, + "text": [ + { + "kind": "keyword", + "keyword": "taunt" + }, + { + "kind": "trigger", + "on": "selfDamaged" + }, + { + "kind": "effect", + "effect": { + "type": "gainAttack", + "target": "self", + "amount": 2 + } + } + ] +} diff --git a/course/12-combat/expansion/tests/rust/test_12x.rs b/course/12-combat/expansion/tests/rust/test_12x.rs new file mode 100644 index 0000000..ee29c60 --- /dev/null +++ b/course/12-combat/expansion/tests/rust/test_12x.rs @@ -0,0 +1,47 @@ +// Module 12 Expansion Drop: Bonechewer Brawler. Matches expansion/README.md. +use engine::harness::*; + +#[test] +fn test_12x_bonechewer_brawler() { + let mut g = new_game(GameOpts { + decks: Some([ + vec!["Test Filler".to_string(); 5], + vec!["Test Filler".to_string(); 5], + ]), + }); + let w = g.summon("Bonechewer Brawler", 1); + let h = g.summon("Loot Hoarder", 1); + let a = g.summon("Harvest Golem", 0); + g.clear_log(); + + let reason = g.can_attack(a, h).expect("the Brawler has Taunt"); + assert!(reason.contains("Taunt"), "{reason}"); + + g.order_attack(a, w).unwrap(); + + assert_eq!( + g.log(), + vec![ + GameEvent::Damaged { + target: w, + amount: 2 + }, + GameEvent::Damaged { + target: a, + amount: 2 + }, + GameEvent::Triggered { + source: w, + trigger: "damaged".to_string() + }, + GameEvent::StatsChanged { + id: w, + attack: 4, + health: 1 + }, + ] + ); + // The Golem took the Brawler's Attack as it was when the damage was dealt. + assert_eq!(g.health(a), 1); + assert_eq!(g.attack(w), 4); +} diff --git a/course/12-combat/expansion/tests/ts/test_12x.test.ts b/course/12-combat/expansion/tests/ts/test_12x.test.ts new file mode 100644 index 0000000..75295b4 --- /dev/null +++ b/course/12-combat/expansion/tests/ts/test_12x.test.ts @@ -0,0 +1,27 @@ +// Module 12 Expansion Drop: Bonechewer Brawler. Matches expansion/README.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +const fillers = (n: number): string[] => Array(n).fill("Test Filler"); + +test("test_12x_bonechewer_brawler", () => { + const g = newGame({ decks: [fillers(5), fillers(5)] }); + const w = g.summon("Bonechewer Brawler", 1); + const h = g.summon("Loot Hoarder", 1); + const a = g.summon("Harvest Golem", 0); + g.clearLog(); + + expect(g.canAttack(a, h)).toContain("Taunt"); + + g.orderAttack(a, w); + + expect(g.log()).toEqual([ + { type: "Damaged", target: w, amount: 2 }, + { type: "Damaged", target: a, amount: 2 }, + { type: "Triggered", source: w, trigger: "damaged" }, + { type: "StatsChanged", id: w, attack: 4, health: 1 }, + ]); + // The Golem took the Brawler's Attack as it was when the damage was dealt. + expect(g.health(a)).toBe(1); + expect(g.attack(w)).toBe(4); +}); diff --git a/course/12-combat/lesson.md b/course/12-combat/lesson.md new file mode 100644 index 0000000..771d9fd --- /dev/null +++ b/course/12-combat/lesson.md @@ -0,0 +1,447 @@ +# Module 12: Combat + +Since Module 2 your minions have taken damage from spells, from triggers and from the harness. They have never hit each other. This module adds the attack: one minion strikes another, and both take damage at once. It also adds the end of a turn, so that a trigger the card format has had since Module 5, `endOfTurn`, finally fires. + +Combat brings a rule that Modules 1 and 2 listed among those the course doesn't model: when two minions trade blows, "the defender ALWAYS triggers first, regardless of order of play" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Every Queue so far has been ordered by order of play and nothing else. This module is about where an exception to that order belongs. The answer the course gives is that the exception is data: a number on each queue entry, read by the same loop that has resolved every trigger since Module 2. + +You need your Module 11 workspace with all Module 1–11 tests passing, under git. This module's tests also use Drakkari Enchanter, which has been in `course/cards/` since Module 5 and never had anything to do. + +## Observe + +All quotes come from the sources in the course's research notes (`docs/research/hearthstone.md`, Source 6). + +**An attack.** The [Attack](https://hearthstone.wiki.gg/wiki/Attack) page: "The attacker and defender deal damage to each other at the same time". The rulebook calls the two sides "the attacker and defender", and puts the damage in one Phase: "Damage is dealt simultaneously in the order (attack, counterattack) and resolved." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). Simultaneous means the same thing as Whirlwind's damage in Module 2: every Damage Event first, then the reactions. + +**Who may attack.** From the Attack page: "Characters with 0 Attack cannot be ordered to attack." Heroes are characters, but "heroes by default have no Attack value", so "enemy minions can hit the hero without harm during the opponent's turn" (same source). A hero that is attacked deals nothing back. + +**Taunt.** The [Taunt](https://hearthstone.wiki.gg/wiki/Taunt) page: "Enemies must attack minions that have Taunt." While there are any, players "cannot attack any other characters while there are enemy Taunt minions on the battlefield", the hero included. With several, "the player will be able to choose which one of them to attack". And Taunt only binds attacks: "Taunts have no effect on spells, Hero Powers or other damaging effects." Unstable Ghoul's card file has carried the keyword `taunt` since Module 5. Your engine has kept it and ignored it. + +**The exception.** The rulebook has a section on the order of damage triggers in the Combat Phase. It begins "If both minions trigger when they take damage (such as a Grim Patron and an Imp Gang Boss), the defender" and goes on: + +> ALWAYS triggers first, regardless of order of play. + +Its example: your Grim Patron attacks an enemy Imp Gang Boss, and the Imp Gang Boss's trigger resolves first, because the defender queues first. The same section has the mirror rule for triggers on *dealing* damage: there, "the attacker ALWAYS triggers first, regardless of order of play." The course has no trigger on dealing damage, so only the first rule matters here. + +Put a board together. You played Acolyte of Pain ("Whenever this minion takes damage, draw a card.") on your turn; your opponent played an Acolyte of Pain after it. Your Acolyte attacks theirs. Both take 1 damage, and both triggers answer. In every Queue since Module 2, yours would go first: it is older. In combat, theirs does. + +**End of turn.** The rulebook's section on the start and end of turn: "All 'end of turn' Triggers (as well as the special case Shadow Madness) are Queued and resolved." and "Enchantments that trigger at the start or end of turn Queue in order of play alongside typical triggers." (same source). An end of turn is a Phase like any other, in order of play. + +**Drakkari Enchanter,** 3-mana 1/5: "Your end of turn effects trigger twice." ([Drakkari Enchanter](https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter)). It is a trigger multiplier, the same kind of aura as Baron Rivendare, and Module 4 already quoted its timing rule: cards like Brann, Baron and Drakkari "check before an Event starts". "This means if one Battlecry/Deathrattle/End of turn effect summons them, it will not trigger again." And: "But Deathrattles/End of turn effects that follow can benefit, for Aura (Other) is updated when you summon them." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). + +## Predict + +Write your answers down before you open the discussion, for example in `notes/12.md` in your workspace. + +1. Your Loot Hoarder (2/1, "Deathrattle: Draw a card.") attacks the enemy's Loot Hoarder. Write the log. Do both die? Whose Deathrattle resolves first? +2. The two Acolytes of Observe. Write the log. Then the enemy's Acolyte attacks yours: what changes? +3. The enemy has Unstable Ghoul (Taunt) and a Loot Hoarder. Can your Harvest Golem attack the Hoarder? The enemy hero? Can your Whirlwind hit the Hoarder? +4. The enemy has 2 Armor. Your Bolf Ramshield (3/9) attacks the enemy hero. Write the log, and Bolf's Health afterwards. +5. The enemy has Bolf Ramshield ("Whenever your hero takes damage, this minion takes it instead.") and a Loot Hoarder. Your Harvest Golem attacks the Hoarder, then the enemy hero. Who takes which damage? Does anything hit the Golem the second time? +6. You have a minion with "At the end of your turn, summon a Drakkari Enchanter." and, played after it, one with "At the end of your turn, deal 1 damage to the enemy hero." Your turn ends. How much damage does the enemy hero take? And if the two were played the other way round? +7. Where in your engine would "the defender triggers first" go? In the code that performs an attack? In the order of the Damage Events? In the Queue? What does each choice make easy or hard? + +
Discussion + +1. Both die, and yours draws first. Both Hoarders deal 2 at the same time, so each deals its damage though the other's kills it. Neither dies on the spot: both are mortally wounded until the outermost Phase ends (Module 3), then both leave in the same Death Creation Step. The Death Phase queues their Deathrattles in order of play, and yours is older. The log: `Damaged` (yours), `Damaged` (theirs), `Destroyed`, `Destroyed`, `DeathPhase 1`, your Deathrattle and draw, their Deathrattle and draw. The two `Damaged` events are in order of play too; see "How the real game differs" under Build for what the rulebook says about that. + +2. Both `Damaged` events, then the enemy Acolyte's trigger and its draw, then yours. When the enemy attacks, the defender is yours, and yours goes first. Order of play didn't decide either case: the role in the attack did. A Module 11 engine, given the same damage, would put your older Acolyte first both times. + +3. No, and no. While the enemy has a Taunt minion, only Taunt minions can be attacked, and the hero is a character like any other. Whirlwind still hits the Hoarder: Taunt has no effect on spells. + +4. `ArmorLost{player: 1, amount: 2}`, then `Damaged{target: enemy hero, amount: 3}`. The hero ends at 29. Combat damage is a Damage like any other, so it goes through Module 4's `DAMAGE_STEPS`. Bolf keeps his 9 Health: a hero has no Attack and hits nothing back. + +5. The Hoarder takes 2 and the Golem takes 2: Bolf's redirect is for damage to its owner's *hero*, and the attack was aimed at a minion. The Hoarder dies and draws. Then the attack on the hero: the redirect moves the damage to Bolf, who takes 2. The Golem takes nothing. The counterattack was decided when the attack began, from the defender, and the defender is the hero, with no Attack. That last part is the course's choice; see Build. + +6. Two, then one. The end of your turn queues both triggers once, in order of play. In the first case, the summoner resolves first, and a summon runs an Aura Update, so when the damage trigger's entry *starts*, Drakkari is in the snapshot: it activates twice. In the second case, the damage trigger started, and counted, before any Drakkari existed. This is the rulebook passage in Observe, and the mechanism is Module 4's, unchanged. + +7. Each place works for the two Acolytes, and each costs something different. + - **In the attack code** ("resolve the defender's trigger, then the attacker's"): the attack bypasses the Queue. The nesting guard, the multipliers, the log depth and everything else the Queue does would have to be repeated or forgotten. + - **In the order of the Damage Events:** create the defender's event first. That fits the rulebook's "(attack, counterattack)". It makes every entry answering the defender's damage come first, which for combat is the same thing. But it is the wrong tool for the rulebook's other exceptions: Redemption "always Queues last" and Poisonous "resolves in a queue with higher priority" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)) are about a *trigger*, not about which event it answers. + - **In the Queue:** give each entry a number, its priority, and order the Queue by priority before order of play. The exception becomes data, carried by the entry. That is what this module builds, and Name it explains where it comes from. + +
+ +## Spec + +From the course repository, copy the module's tests into your workspace: + +```sh +node tools/start-module.mjs 12 ~/my-engine +``` + +### The harness + +Three new functions on `Game` (`course/harness.md`, M12 line): + +| TypeScript | Rust | +|---|---| +| `orderAttack(attacker, defender)` | `order_attack(&mut self, attacker: Id, defender: Id) -> Result<(), String>` | +| `canAttack(attacker, defender): string \| null` | `can_attack(&self, attacker: Id, defender: Id) -> Option` | +| `endTurn(player)` | `end_turn(&mut self, player: Player)` | + +The attack function is called `orderAttack`, after the rulebook's "When you order a minion to attack", because `attack(id)` has read a character's Attack since Module 2. + +- `orderAttack` runs one Sequence. If the attack is illegal, nothing happens: TypeScript throws an `Error` whose message is `canAttack`'s reason, and Rust returns `Err(reason)`. +- `canAttack` returns why the attack is illegal, or `null` (`None`). The reason is yours to word, with two requirements the tests check: it contains `Taunt` when Taunt is what forbids the attack, and `Attack` when 0 Attack does. +- `endTurn(player)` runs one Sequence: the end of that player's turn. Every trigger of that player's minions that listens for `endOfTurn` responds. Its `Triggered` event says `trigger: "endOfTurn"`. + +### The rules + +- **Legality.** The attacker is a minion in play. The defender is an enemy minion in play or the enemy hero. The attacker's Attack is more than 0. While the defender's side has a minion with Taunt, the defender must be one of them. +- **Damage.** One Damage action with two targets, each with its own amount: the attacker's Attack to the defender, and the defender's Attack to the attacker. Both `Damaged` events come before any trigger, and they are in order of play, as for any area damage. A hero, or a minion with 0 Attack, deals nothing back: no `Damaged` event for 0. +- **Priority.** The triggers answering the defender's `Damaged` event go before those answering the attacker's, whatever their order of play. Within each group, the order is the usual one. +- **Then** the end of the outermost Phase, as since Module 3. + +The course has no mana, no limit of one attack per turn, and no summoning sickness: any minion can attack any number of times, and either player's turn can end at any moment. Those are the course's simplifications. + +New card: `Test Bell` (fictional), 0/3, "At the end of your turn, deal 1 damage to the enemy hero." It is in `course/12-combat/cards/`, so `start-module` copies it into your `cards/` from this module on. Its 0 Attack is on purpose: it is the course's minion that can't attack. + +### The tests + +The full statements are in `course/12-combat/scenarios.md`. + +- `test_12_combat_is_simultaneous`: Predict question 1. +- `test_12_defender_triggers_first`: Predict question 2, both ways round. +- `test_12_taunt_restricts_targets`: Predict question 3, and an attack refused with the log left empty. +- `test_12_zero_attack_cannot_attack`: `Test Bell` can't attack; nor can a hero, nor can a minion attack a friendly one. +- `test_12_attack_hero_through_armor`: Predict question 4. +- `test_12_redirect_only_for_the_hero`: Predict question 5. +- `test_12_end_of_turn_trigger`: `Test Bell` answers the end of its owner's turn, and not the other player's. +- `test_12_drakkari_doubles_end_of_turn`: with Drakkari, the Bell activates twice, back to back; the enemy's Bell doesn't. +- `test_12_drakkari_summoned_at_end_of_turn`: Predict question 6, with `Test Conjurer`, a card written as data in the test. + +The heart of the module is the second test: + +```rust +#[test] +fn test_12_defender_triggers_first() { + let mut g = game_with_decks(); + let a = g.summon("Acolyte of Pain", 0); + let d = g.summon("Acolyte of Pain", 1); + g.clear_log(); + + // The attacker is older: order of play alone would put its trigger first. + g.order_attack(a, d).unwrap(); + + let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]); + assert_eq!( + g.log(), + vec![ + damaged(a, 1), + damaged(d, 1), + triggered(d, "damaged"), + GameEvent::Drew { player: 1, id: c1 }, + triggered(a, "damaged"), + GameEvent::Drew { player: 0, id: c0 }, + ] + ); + + // The other way round: the attacker is younger, and the defender still goes first. + g.clear_log(); + g.order_attack(d, a).unwrap(); + assert_eq!(triggered_sources(&g.log()), vec![a, d]); + assert_eq!(g.health(a), 2); + assert_eq!(g.health(d), 2); +} +``` + +```ts +test("test_12_defender_triggers_first", () => { + const g = gameWithDecks(); + const a = g.summon("Acolyte of Pain", 0); + const d = g.summon("Acolyte of Pain", 1); + g.clearLog(); + + // The attacker is older: order of play alone would put its trigger first. + g.orderAttack(a, d); + + const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]]; + expect(g.log()).toEqual([ + { type: "Damaged", target: a, amount: 1 }, + { type: "Damaged", target: d, amount: 1 }, + { type: "Triggered", source: d, trigger: "damaged" }, + { type: "Drew", player: 1, id: c1 }, + { type: "Triggered", source: a, trigger: "damaged" }, + { type: "Drew", player: 0, id: c0 }, + ]); + + // The other way round: the attacker is younger, and the defender still goes first. + g.clearLog(); + g.orderAttack(d, a); + expect(triggeredSources(g.log())).toEqual([a, d]); + expect(g.health(a)).toBe(2); + expect(g.health(d)).toBe(2); +}); +``` + +`game_with_decks` / `gameWithDecks` gives each player a deck of five `Test Filler`; `damaged`, `triggered` and `triggered_sources` / `triggeredSources` are small helpers at the top of the test file. + +Add stubs for the three functions and run `cargo test` or `npm test`. The nine Module 12 tests fail; Modules 1–11 still pass. That is RED. + +## Build + +Make the nine tests pass, and keep Modules 1–11 green. Where code goes: + +- **Combat is rules machinery: `src/engine/`.** Who may attack whom, Taunt, the two amounts and the priority are rules of the game, as the Queue and the Death Creation Step are. The engine may know the keyword `taunt`, as it knows `deathrattle`. It must not know Unstable Ghoul. +- **One Sequence, through the Queue.** `orderAttack` hands one action to the engine, and the damage it causes goes through the same `perform` and the same Phase function as Whirlwind's. Don't resolve the two minions' triggers from the attack code. +- **The exception lives in the Queue.** The Phase function gets a priority for each event's entries and orders its Queue by priority first. Nothing else in the Phase function changes. +- **Legality is a question the engine answers.** `canAttack` and `orderAttack` ask the same function. +- **The end of a turn is an event.** `endTurn` raises it; `endOfTurn` triggers of that player's minions answer it, through the Phase function, so Drakkari's multiplier applies with no new code. +- `src/harness` stays thin. + +
Hint 1 — nudge + +There are three separate jobs, and only one of them is new. Your Damage action deals one amount to all its targets: an attack needs two amounts in one action, or the damage isn't simultaneous. Your Phase function orders its Queue by order of play: an attack needs one group of entries to go first. And `endOfTurn` needs an event to answer. Which of the three would you still need if Hearthstone had no "regardless of order of play"? + +Then: the Queue is filled in order of play and locked before the first entry resolves. Where, between those two moments, could you put the defender's entries first? + +
+ +
Hint 2 — concept + +- **Hits.** Change the Damage action from "these targets, this amount" to a list of hits: a target, an amount and a priority each. Area damage and spells make hits with one amount and the normal priority. The Damage Events are still created in order of play of the targets. +- **Priority.** A number on each queue entry, copied from the hit whose Damage Event the entry answers. After the Queue is filled, sort it by priority, highest first, with a **stable** sort, so entries of equal priority keep the order of play they were filled in. Then lock it and resolve it as before. Death Events, Battlecries and the end of a turn all have the normal priority. +- **The attack.** Check legality, then read both Attacks, then perform one Damage action: the attacker's Attack to the defender, with a higher priority; the defender's Attack to the attacker, with the normal priority, only if it is more than 0. +- **Legality.** One function, returning a reason or nothing: attacker on a board, defender an enemy minion on a board or the enemy hero, Attack above 0, and the Taunt rule. +- **Taunt.** Your compiled card can keep a list of keywords. A minion has Taunt if `taunt` is in it. +- **End of turn.** An event with the player whose turn ended. `endOfTurn` responds when the listener's owner is that player. + +
+ +
Hint 3 — pseudo-code + +```text +NORMAL = 0 +DEFENDER = 1 + +canAttack(attacker, defender): + if attacker is not a minion on a board: return "the attacker is not a minion in play" + if defender is not an enemy minion on a board + and not the enemy hero: return "the defender is not an enemy character in play" + if attack(attacker) <= 0: return "a minion with 0 Attack cannot attack" + taunts = enemy board minions with keyword taunt + if taunts not empty and defender not in taunts: return "a minion with Taunt is in the way" + return none + +perform(Attack(attacker, defender)): + require canAttack(attacker, defender) is none + hits = [(defender, attack(attacker), DEFENDER)] // both read before either is dealt + if attack(defender) > 0: hits.push((attacker, attack(defender), NORMAL)) + perform(Damage(hits)) + +perform(Damage(hits)): + events = []; priorities = [] + for h in hits sorted by orderOfPlay(h.target): + events.push(applyDamage(h.target, h.amount)); priorities.push(h.priority) + react(events, priorities) + +perform(EndTurn(p)): + react([TurnEnded(p)], []) + +react(events, priorities): + queue = [] + for i, e in events: // as before, plus the priority + for m in listeners(e), trig in m.card.triggers: + if responds(trig.on, m, e) and not resolving(m, trig): + queue.push((m, trig, e, priorities[i] or NORMAL)) + stable sort queue by priority, highest first // the only new line + for (m, trig, e, _) in queue: ... as before ... + +responds(endOfTurn, m, TurnEnded(p)) -> owner(m) == p +``` + +
+ +When your tests pass, compare with the reference engine in `course/12-combat/reference/rust/src/engine/` or `course/12-combat/reference/ts/src/engine/`: the new `combat` (legality and Taunt), `queue` (`Hit`, `Priority`, the `Attack` and `EndTurn` actions, and `react`), `card` (keywords) and `event` (`TurnEnded`). + +Decisions in the reference that the tests don't force: + +- **Every entry answering the defender's damage goes first,** not only the defender's own trigger. The rulebook's sentence is about two minions that each trigger "when they take damage". An Armorsmith beside the defender answers the same Damage Event, and in the reference it shares the event's priority. The course's reading: the exception is about the event, and the entry carries the event's priority. No test has a third minion. +- **No `Damaged` for 0.** A hero, or a 0 Attack minion, as defender adds no hit at all, so no `Damaged{amount: 0}` event and no "takes damage" trigger. The Attack page's "hit the hero without harm" suggests nothing happens to the attacker. +- **An illegal attack changes nothing and records nothing.** `orderAttack` asks `canAttack` before the Sequence starts. +- **A redirected attack has no counterattack from the redirect.** When Bolf takes an attack aimed at his hero, the defender is still the hero, whose Attack was read when the attack began. Bolf doesn't hit the attacker. The [Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield) page confirms that he intercepts attacks: effects of "the attacking character dealing damage" "will still take effect if Bolf Ramshield intercepts the damage". It says nothing about Bolf hitting back. That part is the course's choice. +- **The end of a turn is an engine event the log doesn't show,** as `Played` isn't shown (Module 4). The triggers that answer it are the log's evidence. + +> **How the real game differs** +> - **The order of the `Damaged` events.** The rulebook says the damage is dealt "in the order (attack, counterattack)" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)): the defender's Damage Event first. The course creates the two events in order of play, as for every Damage action since Module 2, and puts the exception in the Queue. The triggers come out in the same order either way for every board in this module; only the order of the two `Damaged` lines differs. Stress it question 1 asks what the difference would buy. +> - **Triggers on dealing damage.** The rulebook's mirror rule, "the attacker ALWAYS triggers first, regardless of order of play" (same source), isn't modelled: the course has no trigger on dealing damage. +> - **The rest of the attack.** In the game an attack is "a Sequence of two Phases - "Preparation" and "Attack"" (same source), with a Proposed Attack Event that can change the defender, an Attack Event, and an After Attack Event. The game also checks for a win or a loss between the Phases. The course's attack is one Phase: the damage. +> - **Turns.** "A character can usually attack up to one time per turn" ([Attack](https://hearthstone.wiki.gg/wiki/Attack)), new minions have "summoning sickness" (same source), and attacking costs nothing but playing costs mana. The course models none of these, and only minions attack: heroes with weapons are not in the course. `endTurn` can be called for either player at any moment, and there is no start of turn. +> - **Stealth.** "The Taunt effect is temporarily negated by the Stealth or Immunity." ([Taunt](https://hearthstone.wiki.gg/wiki/Taunt)). The course has neither. +> - **Drakkari's exceptions.** "Drakkari Enchanter doesn't affect some end of turn effects." ([Drakkari Enchanter](https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter)). The page doesn't list them; the course's Drakkari doubles every `endOfTurn` trigger of its owner's minions. + +## Name it + +The pattern is **priority within a queue**. A Queue resolves its entries by a key with two parts: first a priority, then order of play. Almost every entry has the same priority, so order of play decides, as it has since Module 2. An exception to that order is not a branch in the resolver. It is a different number on the entry. + +You have seen this before. Module 2's Further reading pointed at the Liquid Fire action system, where every action has a field "public int priority { get; set; }". The article says what it is for: "The “priority” is most important and is a way to override the default sorting in “orderOfPlay”", and how it is used: "The final result will order them first by “priority” in descending order (higher priority goes first)". Its `SortActions` is the whole rule ([Jon Parham, *Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/)): + +```text +if (x.priority != y.priority) { +return y.priority.CompareTo(x.priority); +} else { +return x.orderOfPlay.CompareTo(y.orderOfPlay); +``` + +The reference does the same with one sort, after the Queue is filled and before it is locked: + +```rust +// Priority first; the sort is stable, so entries of one priority keep the order above. +queue.sort_by_key(|entry| std::cmp::Reverse(entry.4)); +``` + +```ts +// Priority first; the sort is stable, so entries of one priority keep the order above. +queue.sort((a, b) => b[4] - a[4]); +``` + +The tie-breaker is the order the Queue was filled in, which is order of play: for each event in order of play, each listener in order of play. That is why the sort must be **stable**. Rust's `sort_by_key` is stable, and so is JavaScript's `Array.prototype.sort`. An unstable sort would keep the defender's entries first and shuffle everything else, and every Module 2 test would start failing at random. + +And the attack, which is where the priority comes from: + +```rust +Action::Attack { attacker, defender } => { + if let Some(error) = attack_error(world, attacker, defender) { + panic!("{error}"); + } + // Both amounts are read before either is dealt: the damage is simultaneous. A + // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion. + let mut attack = vec![Hit { + target: defender, + amount: world.attack(attacker), + priority: DEFENDER, + }]; + let counter = world.attack(defender); + if counter > 0 { + attack.push(Hit { + target: attacker, + amount: counter, + priority: NORMAL, + }); + } + perform(world, Action::Damage { hits: attack }); +} +``` + +```ts +case "attack": { + const { attacker, defender } = action; + const error = attackError(world, attacker, defender); + if (error !== undefined) throw new Error(error); + // Both amounts are read before either is dealt: the damage is simultaneous. A + // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion. + const attack: Hit[] = [{ target: defender, amount: world.attack(attacker), priority: DEFENDER }]; + const counter = world.attack(defender); + if (counter > 0) attack.push({ target: attacker, amount: counter, priority: NORMAL }); + perform(world, { type: "damage", hits: attack }); + break; +} +``` + +Notice what the attack does *not* do. It doesn't resolve a trigger, and it doesn't know which minions have triggers. It describes two hits and says which one matters more; the one Phase function does the rest, with the nesting guard, the multipliers and the log tree it already had. + +The course has answered ordering questions with data before. Module 4 put the stacking policy of a multiplier in a field, and the damage steps in a list, `DAMAGE_STEPS`. Module 9 put the turn's phases in a list, and Module 10 did the same for the steps at the end of the outermost Phase, `END_OF_PHASE`. Each time, the order became something you can read in one place and change in one place. A priority is the same move made *inside* a Queue: the Queue's order is still one rule, written once, and the exceptions are values it reads. + +**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "attack", "Taunt" and "end of turn". It must not know Unstable Ghoul or Drakkari Enchanter. + +## Expansion Drop + +A new card arrives: Bonechewer Brawler, 2-mana 2/3, "Taunt. Whenever this minion takes damage, gain +2 Attack." ([Bonechewer Brawler](https://hearthstone.wiki.gg/wiki/Bonechewer_Brawler)). + +The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't change either. Only your content, and your `cards/`, should. + +1. Commit your workspace and note the commit: `git add -A && git commit -m "module 12 done"`. +2. Copy the expansion test for your track from `course/12-combat/expansion/tests/rust/test_12x.rs` or `course/12-combat/expansion/tests/ts/test_12x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Add Bonechewer Brawler to your cards. +4. When the tests pass, run the check from the course repository: + +```sh +node tools/expansion-check.mjs ~/my-engine HEAD +``` + +The test, `test_12x_bonechewer_brawler`, puts a Brawler and a Loot Hoarder on the enemy's side. The Hoarder can't be attacked: the Brawler has Taunt. A Harvest Golem attacks the Brawler. Both take 2; then the Brawler's trigger gives it +2 Attack. The Golem is left at 1 Health: it took the Brawler's Attack as it was when the damage was dealt, not after the trigger. The full scenario is in `course/12-combat/expansion/README.md`. + +
Discussion: what this drop measures + +If Taunt is a keyword your combat reads, and a trigger is a condition from a small vocabulary plus effects, the Brawler is one card file: + +```json +{ + "name": "Bonechewer Brawler", + "type": "minion", + "cost": 2, + "attack": 2, + "health": 3, + "split": 1, + "text": [ + { "kind": "keyword", "keyword": "taunt" }, + { "kind": "trigger", "on": "selfDamaged" }, + { "kind": "effect", "effect": { "type": "gainAttack", "target": "self", "amount": 2 } } + ] +} +``` + +That is the reference answer in `course/12-combat/expansion/solution/cards/`, where the same clauses are spread over more lines. Every clause is a word the course already had: Unstable Ghoul's `taunt`, Acolyte of Pain's `selfDamaged` and Frothing Berserker's `gainAttack` on `self`. + +Shapes that fail this drop: + +- **Taunt by name.** If your legality check asks "is an Unstable Ghoul on the board?", the Brawler is a second name. The audit should have caught it. If it slipped through as a flag on the card type, the Brawler needs the flag set in code. +- **The keyword dropped when the card is compiled.** If your compiled card kept only triggers and auras, as it could until this module, the Brawler's `taunt` is lost and the Hoarder can be attacked. The tests of this module only use Unstable Ghoul's Taunt, so check that your engine reads the keyword from the data and not from somewhere else. +- **Damage read after the triggers.** If your attack dealt the attacker's damage, resolved the reactions, and only then read the defender's Attack for the counterattack, the Golem takes 4 and dies. The damage has to be decided before anything answers it. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Stress it + +Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion. + +1. Take the priority out: delete the sort in your Phase function. Which tests fail? Now put it back, and instead create the two `Damaged` events of an attack in the rulebook's order, defender first, with no priority at all. Which tests fail now, and why? If both designs give the same triggers, which would you keep? + +
Discussion + +Without the sort, only `test_12_defender_triggers_first` fails: the older attacker's trigger comes first. One scenario pins the exception, and in the reference no other test in Modules 1–12 notices it is gone, because no other test has two minions answering two Damage Events of different priority. + +With the events in the rulebook's order and no priority, the triggers come out the same: filling the Queue "for each event, each listener" already puts everything that answers the defender's event first. What fails is the log, where the attacker is older: `test_12_combat_is_simultaneous` and the first half of `test_12_defender_triggers_first` expect the two `Damaged` events in order of play, and now the defender's comes first. Where the defender is older, as in `test_12_taunt_restricts_targets`, the two orders agree and nothing fails. Those tests encode the course's choice, not a fact about the game, and "How the real game differs" says so. + +Which to keep? The event order is closer to the rulebook's text, and it explains *both* combat rules at once: on damage taken, the defender's event comes first; on damage dealt, the event the attacker dealt comes first, so "the attacker ALWAYS triggers first". The priority is closer to the rulebook's other exceptions. Poisonous "resolves in a queue with higher priority" and Redemption "always Queues last" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)): those are about which *trigger* is queued, and no event order can express them. The course picked the mechanism that covers all three. An engine that also had triggers on dealing damage might want both: events in (attack, counterattack) order, and a priority for the rest. + +
+ +2. Add a fictional card: Test Martyr, 1/1, "Whenever a minion takes damage, draw a card." with the rule that it "always queues last", as Redemption does. Where does that priority come from: the event, the trigger, or the card? What happens when a Martyr answers the defender's damage in an attack? + +
Discussion + +From the trigger. In this module the only priority comes from the *event* (the defender's Damage Event), and every entry answering it inherits it. Redemption's comes from what the trigger is. So an entry now has two sources of priority, and the engine needs a rule to combine them. That is Module 4's Stress it question 4 again: two modifiers touch the same value, and something must decide how they combine. "Queues last" means last in the whole Queue, so the trigger's rule should win: the entry's priority is the trigger's if it has one, else the event's. Or give priorities ranges ("last" is a very low number, "defender" a small positive one) and add them. Either way, write the rule down once, in the function that computes an entry's priority, and not in the sort. + +The card format would gain a field on a trigger clause, and the engine would read it. That is an engine change, but a small one, and after it a new "queues first" or "queues last" card is data. The rulebook's wording supports the idea of priority levels: Poisonous "resolves in a queue with higher priority" (same source) reads like a separate level, not a special case. + +
+ +3. Hearthstone limits attacks: "A character can usually attack up to one time per turn" ([Attack](https://hearthstone.wiki.gg/wiki/Attack)), and a minion that has just been played can't attack yet. What would your engine have to remember to enforce both, where would `canAttack` read it, and which events would change it? + +
Discussion + +Two facts per minion: how many times it has attacked this turn, and whether it entered play this turn. Both are state on the entity, set by actions the engine already performs: an attack raises the count; entering play (a play, a summon) marks it new. And both are cleared by something the course doesn't have yet: the *start* of its owner's turn. `endTurn(player)` raises the end of one player's turn, but nothing says whose turn it is next, and the harness lets a test end either player's turn at any moment. + +So the real cost isn't the two counters. It is that "turn" would become state of the game: whose turn it is, which actions that player may take, and a start-of-turn event that resets the counters before any start-of-turn trigger runs. Which of those comes first is another ordering question with an answer to write down. The course's simplification keeps every test free to set up a board and attack straight away; Module 2 made the same trade when it gave the course no mana. + +
+ +4. Bolf Ramshield takes the damage of an attack on his hero, and in the reference he doesn't hit the attacker. Argue for the opposite: the redirect changes the defender. What would have to change in the order of things, and what would the Golem in `test_12_redirect_only_for_the_hero` take? + +
Discussion + +If Bolf became the defender, the Golem would take 3, Bolf's Attack, and die. To build it, the counterattack can no longer be decided when the attack starts: the attack would have to ask "who will actually take this damage?" first, which means running the redirect step of `DAMAGE_STEPS` before the hits are made, and then again, or not, inside `applyDamage`. Two places would now know about redirects, and the "applies at most once" guard of Module 4 would have to hold across both. + +The game has a separate mechanism for changing a defender: the Proposed Attack Event, whose triggers "include those that can change the defender" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). That happens before the Combat Phase, and is a different thing from a predamage redirect like Bolf's. The course has no Proposed Attack Event, and the Bolf Ramshield page says nothing about Bolf hitting back, so the course keeps the simpler rule: the defender is fixed when the attack starts, and a redirect only moves damage. + +
+ +## Further reading + +- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): the Combat section (the Preparation and Combat Phases, the Proposed Attack, Attack and After Attack Events, and the ordering of damage triggers in the Combat Phase, with the Grim Patron example), and the section on the start and end of turn. +- [Attack](https://hearthstone.wiki.gg/wiki/Attack) and [Taunt](https://hearthstone.wiki.gg/wiki/Taunt), hearthstone.wiki.gg: who may attack, how often, and what Taunt does and doesn't stop. +- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017), again. Reread `SortActions` and the paragraph on `priority`. Then compare: in that design an action's priority is set when the reaction is created; in the reference it comes from the event the reaction answers. Which would you need for Redemption? +- [Drakkari Enchanter, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter): its examples (Dreadsteed, Echoing Ooze, Gruul), each an end of turn effect doubled. diff --git a/course/12-combat/reference/rust/.course.json b/course/12-combat/reference/rust/.course.json new file mode 100644 index 0000000..ce7c632 --- /dev/null +++ b/course/12-combat/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "12" } diff --git a/course/12-combat/reference/rust/.gitignore b/course/12-combat/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/12-combat/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/12-combat/reference/rust/Cargo.lock b/course/12-combat/reference/rust/Cargo.lock new file mode 100644 index 0000000..6b7cc4c --- /dev/null +++ b/course/12-combat/reference/rust/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "engine" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/course/12-combat/reference/rust/Cargo.toml b/course/12-combat/reference/rust/Cargo.toml new file mode 100644 index 0000000..0987cde --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/README.md b/course/12-combat/reference/rust/README.md new file mode 100644 index 0000000..c664e5c --- /dev/null +++ b/course/12-combat/reference/rust/README.md @@ -0,0 +1,14 @@ +# Module 12 reference engine (Rust) + +The course's own engine as of the end of Module 12: Module 11's engine (Part I's card engine, Part II's grid engine with conditions, and SpellGrid joining them), with combat and the end of a turn in Part I. Every test from Modules 1–11 still passes. Module 11's reference was the starting point, so a diff between the two shows what this module adds. `course/11-conditions/reference/rust/README.md` and the READMEs it points to describe everything else. + +- `cards/` and `levels/` (at the crate root; not in this directory): staged by `tools/verify-references.mjs` as for Module 11, plus `course/12-combat/cards/` (`Test Bell`). +- Part I (`src/engine/`): + - `combat.rs` (new): `attack_error(world, attacker, defender)`, the legality rules (a minion in play attacks an enemy character in play, with more than 0 Attack, and a Taunt minion if the defender's side has one), and `TAUNT`. + - `card.rs`: `Card::keywords`, compiled from keyword clauses. `TriggerOn::EndOfTurn` now fires. + - `event.rs`: `Event::TurnEnded { player }`, which end of turn triggers answer. The course's log doesn't show it. + - `queue.rs`: `Action::Damage` carries `hits`, each a target, an amount and a `Priority` (`hits(targets, amount)` builds the usual one-amount list). `react` takes the priority of each event's entries and sorts its Queue by priority, highest first, keeping the order it was filled in within one priority. `Action::Attack` builds one Damage action: the attacker's Attack to the defender at priority `DEFENDER`, and the defender's Attack (if more than 0) to the attacker at `NORMAL`. `Action::EndTurn` records `TurnEnded` and resolves the triggers that answer it. +- `src/harness.rs`: `order_attack(attacker, defender) -> Result<(), String>`, `can_attack(attacker, defender) -> Option` and `end_turn(player)` on `Game`. +- Part II, Part III, `src/cli.rs` and `src/bin/spellgrid.rs`: unchanged from Module 11. SpellGrid doesn't attack. `tools/diff-tracks.mjs` uses this module's CLIs by default. + +Run it with the module's tests: `node tools/verify-references.mjs 12` from the course repository. diff --git a/course/12-combat/reference/rust/src/bin/spellgrid.rs b/course/12-combat/reference/rust/src/bin/spellgrid.rs new file mode 100644 index 0000000..181b5aa --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/src/cli.rs b/course/12-combat/reference/rust/src/cli.rs new file mode 100644 index 0000000..2e0e5b3 --- /dev/null +++ b/course/12-combat/reference/rust/src/cli.rs @@ -0,0 +1,200 @@ +//! 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. After a command that ran Part I +//! Sequences, it prints their events under `Log:` as an indented tree. +//! +//! ```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::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, separator: &str, none: &str| { + if list.is_empty() { + none.to_string() + } else { + list.join(separator) + } + }; + lines.push(format!("Minions: {}", or(minions, "; ", "none"))); + lines.push(format!("Hand: {}", or(g.hand(), ", ", "empty"))); + lines.join("\n") +} + +/// A coordinate, or a chooser's number: one to nine digits, nothing else (no sign, no point). +fn number(word: &str) -> Option { + let digits = (1..=9).contains(&word.len()) && word.bytes().all(|b| b.is_ascii_digit()); + digits.then(|| word.parse().expect("nine digits fit an i32")) +} + +/// A `c` command: `c [x y]`. +fn parse_cast(args: &[&str]) -> (String, Option<(i32, i32)>) { + let n = args.len(); + if n >= 3 { + if let (Some(x), Some(y)) = (number(args[n - 2]), number(args[n - 1])) { + 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 = number(&answer) + .and_then(|n| usize::try_from(n - 1).ok()) + .and_then(|i| options.get(i)); + picked.unwrap_or(&options[0]).clone() + }); + play(&mut g, &level, &io) +} + +/// 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(); + // The log holds only what this command does. + g.clear_log(); + 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, + }; + // The harness panics on a move the rules don't allow (a card not in the hand, a + // full tile). The CLI asks first, and reports the reason instead. + let done = match (dir, words.as_slice()) { + (Some(dir), _) => Ok(g.step(dir)), + (None, ["c", args @ ..]) if !args.is_empty() => match parse_cast(args) { + (card, None) => g.check_cast(&card, None).map(|()| g.cast(&card, None)), + (card, Some((x, y))) => match g.minion_at(x, y) { + Some(target) => g + .check_cast(&card, Some(target)) + .map(|()| g.cast(&card, Some(target))), + None => g.check_play(&card, x, y).map(|()| { + g.play(&card, x, y); + }), + }, + }, + _ => { + Err("Commands: w a s d (move), c [x y] (cast or play), q (quit)".to_string()) + } + }; + if let Err(message) = done { + io.borrow_mut().write(&format!("{message}\n")); + } + let tree = g.log_tree(); + if !tree.is_empty() { + io.borrow_mut().write(&format!("Log:\n{tree}")); + } + } +} diff --git a/course/12-combat/reference/rust/src/content/conditions.rs b/course/12-combat/reference/rust/src/content/conditions.rs new file mode 100644 index 0000000..34aca78 --- /dev/null +++ b/course/12-combat/reference/rust/src/content/conditions.rs @@ -0,0 +1,54 @@ +//! Part II: what each condition word means (Module 11). The engine asks these checks +//! every time it is about to use a rule with a condition, for one object, against the +//! board as it is then. A new condition word is one entry in `words.json`, with role +//! "condition", and one check here. + +use std::collections::HashSet; + +use crate::engine::puzzle::{ConditionCheck, ConditionChecks, Level, ObjectId}; + +/// The condition checks, by condition word. +pub fn conditions() -> ConditionChecks { + ConditionChecks::from([("ON".to_string(), on as ConditionCheck)]) +} + +/// Can each noun be given an object of its own among `others`, one the noun names? +/// `ON ROCK AND ROCK` needs two rocks (the wiki: "AND in the noun2 part requires at +/// least as many objects as are mentioned"). Tries every assignment. +fn each_has_one(level: &Level, nouns: &[String], others: &[ObjectId]) -> bool { + fn assign( + level: &Level, + nouns: &[String], + others: &[ObjectId], + taken: &mut HashSet, + ) -> bool { + let Some((noun, rest)) = nouns.split_first() else { + return true; + }; + for &other in others { + if taken.contains(&other) || !level.names(noun, other) { + continue; + } + taken.insert(other); + if assign(level, rest, others, taken) { + return true; + } + taken.remove(&other); + } + false + } + assign(level, nouns, others, &mut HashSet::new()) +} + +/// ON: for each noun, another object on the object's tile that the noun names, a +/// different one for each noun. Float levels don't matter ("ON ignores FLOAT levels"). +fn on(level: &Level, id: ObjectId, nouns: &[String]) -> bool { + let o = level.board().get(id); + let others: Vec = level + .board() + .at(o.x, o.y) + .map(|other| other.id) + .filter(|&other| other != id) + .collect(); + each_has_one(level, nouns, &others) +} diff --git a/course/12-combat/reference/rust/src/content/hazards.rs b/course/12-combat/reference/rust/src/content/hazards.rs new file mode 100644 index 0000000..c86a289 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/src/content/mod.rs b/course/12-combat/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..557f691 --- /dev/null +++ b/course/12-combat/reference/rust/src/content/mod.rs @@ -0,0 +1,63 @@ +//! 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 conditions; +mod hazards; +mod movement; +pub use conditions::conditions; +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. A condition word (Module 11) also +/// needs its check in `conditions.rs`. +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/12-combat/reference/rust/src/content/movement.rs b/course/12-combat/reference/rust/src/content/movement.rs new file mode 100644 index 0000000..c918757 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/src/content/seams.json b/course/12-combat/reference/rust/src/content/seams.json new file mode 100644 index 0000000..656e98b --- /dev/null +++ b/course/12-combat/reference/rust/src/content/seams.json @@ -0,0 +1,4 @@ +{ + "triggerWords": { "DEATHRATTLE": "deathrattle" }, + "multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } } +} diff --git a/course/12-combat/reference/rust/src/content/words.json b/course/12-combat/reference/rust/src/content/words.json new file mode 100644 index 0000000..cc2de5f --- /dev/null +++ b/course/12-combat/reference/rust/src/content/words.json @@ -0,0 +1,36 @@ +[ + { "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": "ALL", "role": "noun", "selects": "things" }, + { "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": "CRIER", "role": "noun" }, + { "name": "GOLEM", "role": "noun" }, + { "name": "SHARD", "role": "noun" }, + { "name": "DEATHRATTLE", "role": "noun" }, + { "name": "ON", "role": "condition" }, + { "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/12-combat/reference/rust/src/engine/assemble.rs b/course/12-combat/reference/rust/src/engine/assemble.rs new file mode 100644 index 0000000..f3230f6 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/assemble.rs @@ -0,0 +1,174 @@ +//! 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::{well_formed, 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", + } + } +} + +/// Why an Assemble was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssembleFailure { + /// The new text would equal the base's. + Unchanged, + /// The new text would not be well-formed (see `well_formed`). + IllFormed, +} + +/// Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssembleError { + pub base: String, + pub donor: String, + pub half: Half, + pub reason: AssembleFailure, +} + +impl fmt::Display for AssembleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let result = match self.reason { + AssembleFailure::Unchanged => "leaves its text unchanged", + AssembleFailure::IllFormed => "gives ill-formed text", + }; + write!( + f, + "assembling {:?} with the {} half of {:?} {result}", + 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(); + let refuse = |reason| AssembleError { + base: base.name.clone(), + donor: donor.name.clone(), + half, + reason, + }; + if text == base.text { + return Err(refuse(AssembleFailure::Unchanged)); + } + if !well_formed(&text, base.card_type) { + return Err(refuse(AssembleFailure::IllFormed)); + } + 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, and the half to replace. Only combinations that change the +/// base's text and leave it well-formed are offered: a donor is offered if one of its +/// halves gives such a text, and a base if it has such a donor. 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(); + // Does assembling `base` with `donor`'s `half` give a new, well-formed text? + let changes = |base: &CardDef, donor: &CardDef, half: Half| assemble(base, donor, half).is_ok(); + let donors_for = |base: &CardDef| -> Vec { + pool.iter() + .map(|(d, _)| d) + .filter(|d| d.name != base.name && Half::BOTH.iter().any(|&h| changes(base, d, h))) + .map(|d| d.name.clone()) + .collect() + }; + let bases: Vec = pool + .iter() + .filter(|(d, allowed)| *allowed && !donors_for(d).is_empty()) + .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 donor_name = world.choose("donor", &donors_for(&base)); + let donor = world.card(&donor_name).def.clone(); + + let halves: Vec = Half::BOTH + .iter() + .filter(|&&h| changes(&base, &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 give a new, well-formed text are offered"); + let name = register_assembled(world, def); + Some(world.create(&name, player, Zone::Hand)) +} diff --git a/course/12-combat/reference/rust/src/engine/aura.rs b/course/12-combat/reference/rust/src/engine/aura.rs new file mode 100644 index 0000000..1df46dc --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/aura.rs @@ -0,0 +1,84 @@ +//! Auras, the one routine that applies trigger multipliers, and the lookup for +//! damage redirects. +//! +//! 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, EntityId, 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), + source: Some(id), + aura, + }); + } + } + for &aura in world.rule_auras() { + for owner in 0..2 { + active.push(ActiveAura { + owner, + source: None, + aura, + }); + } + } + world.set_active_auras(active); +} + +/// The minion that takes damage meant for `player`'s hero, if a redirect is in force: +/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the +/// snapshot. The snapshot lists minions oldest first, so that is the first one found. +pub fn redirect_for(world: &World, player: PlayerId) -> Option { + world + .active_auras() + .iter() + .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player) + .and_then(|a| a.source) +} + +/// 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/12-combat/reference/rust/src/engine/card.rs b/course/12-combat/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..7a0e500 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/card.rs @@ -0,0 +1,348 @@ +//! 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 (Module 12: `end_turn`). + 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 }, + /// Damage that would be dealt to the aura owner's hero is dealt to the aura's + /// minion instead (see `DAMAGE_STEPS` in `queue.rs`). + DamageRedirect { from: RedirectFrom, to: RedirectTo }, +} + +/// Whose damage a redirect takes: the card format's `"friendlyHero"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RedirectFrom { + FriendlyHero, +} + +/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum RedirectTo { + #[serde(rename = "self")] + This, +} + +/// 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, + /// Keywords, such as `"taunt"` (Module 12: combat reads it). + pub keywords: 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 keywords = 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; + } + // Module 12: combat reads Taunt (`combat.rs`). + Clause::Keyword { keyword } => { + keywords.push(keyword.clone()); + open_trigger = false; + } + } + } + let in_pool = in_pool && def.card_type == CardType::Minion && !def.text.is_empty(); + Card { + def, + triggers, + auras, + keywords, + 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) + } +} + +/// Does `text` mean what it says? For a minion, every effect clause belongs to a +/// trigger (none comes before the first trigger, or after an aura or keyword), and +/// every trigger has at least one effect before the next trigger, aura, keyword or +/// the end. The rule is for minions (Assemble makes only minions): a spell passes. +pub fn well_formed(text: &[Clause], card_type: CardType) -> bool { + if card_type != CardType::Minion { + return true; + } + // The trigger whose effects are being listed: none, one with no effect yet, or one with some. + #[derive(PartialEq)] + enum Open { + None, + Empty, + Filled, + } + let mut open = Open::None; + for clause in text { + match clause { + Clause::Effect { .. } if open == Open::None => return false, + Clause::Effect { .. } => open = Open::Filled, + _ if open == Open::Empty => return false, + Clause::Trigger { .. } => open = Open::Empty, + Clause::Aura { .. } | Clause::Keyword { .. } => open = Open::None, + } + } + open != Open::Empty +} + +/// 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/12-combat/reference/rust/src/engine/combat.rs b/course/12-combat/reference/rust/src/engine/combat.rs new file mode 100644 index 0000000..339e417 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/combat.rs @@ -0,0 +1,49 @@ +//! Combat (Module 12): who may attack whom. +//! +//! An attack is one Sequence (`Action::Attack` in `queue.rs`): both characters deal their +//! Attack to each other at once, as one Damage action with two targets, and the +//! defender's Damage Event queues its triggers first. This file holds only the +//! legality rules, so the harness can ask them without attacking. + +use super::world::{EntityId, World, Zone}; + +/// The keyword that makes a minion the only thing its enemies may attack. +pub const TAUNT: &str = "taunt"; + +/// Does `id`, a minion in play, have Taunt? +pub fn has_taunt(world: &World, id: EntityId) -> bool { + world + .card(world.card_name(id)) + .keywords + .iter() + .any(|k| k == TAUNT) +} + +/// Why `attacker` may not attack `defender` now, or `None` if it may. The rules, in +/// the order they are checked: the attacker is a minion in play; the defender is an +/// enemy minion in play or the enemy hero; the attacker has some Attack; and while the +/// defender's side has a minion with Taunt, only such a minion may be attacked. +pub fn attack_error(world: &World, attacker: EntityId, defender: EntityId) -> Option { + if world.is_hero(attacker) || world.zone_of(attacker) != Zone::Board { + return Some("the attacker is not a minion in play".to_string()); + } + let enemy = 1 - world.owner_of(attacker); + let enemy_character = world.owner_of(defender) == enemy + && (world.is_hero(defender) || world.zone_of(defender) == Zone::Board); + if !enemy_character { + return Some("the defender is not an enemy character in play".to_string()); + } + if world.attack(attacker) <= 0 { + return Some("a minion with 0 Attack cannot attack".to_string()); + } + let taunts: Vec = world + .zone(enemy, Zone::Board) + .iter() + .copied() + .filter(|&id| has_taunt(world, id)) + .collect(); + if !taunts.is_empty() && !taunts.contains(&defender) { + return Some("a minion with Taunt is in the way".to_string()); + } + None +} diff --git a/course/12-combat/reference/rust/src/engine/death.rs b/course/12-combat/reference/rust/src/engine/death.rs new file mode 100644 index 0000000..fbec8a9 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/death.rs @@ -0,0 +1,41 @@ +//! 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::{EntityId, World, Zone}; + +/// 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 neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + let mut deaths = Vec::new(); + for id in world.minions_in_play() { + if world.is_doomed(id) { + let before = survivor_to_the_right(world, id); + deaths.push(Event::Destroyed { id, before }); + } + } + for death in &deaths { + if let Event::Destroyed { id, before } = *death { + world.kill(id, before); + } + } + deaths +} + +/// The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. +fn survivor_to_the_right(world: &World, id: EntityId) -> Option { + world + .zone(world.owner_of(id), Zone::Board) + .iter() + .copied() + .skip_while(|&other| other != id) + .skip(1) + .find(|&other| !world.is_doomed(other)) +} diff --git a/course/12-combat/reference/rust/src/engine/effect.rs b/course/12-combat/reference/rust/src/engine/effect.rs new file mode 100644 index 0000000..4a5d03a --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/effect.rs @@ -0,0 +1,97 @@ +//! 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::{hits, 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 { + hits: hits(&targets(*target, ctx), *amount), + }], + Effect::Draw { count } => (0..*count) + .map(|_| Action::Draw { player: owner }) + .collect(), + // Several tokens stand left to right in summon order: each goes right of the one before. + Effect::Summon { card, count } => { + let at = summon_position(ctx); + (0..*count) + .map(|i| Action::Summon { + card: card.clone(), + player: owner, + at: at.map(|at| at + i as usize), + }) + .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 (left of the minion its Death Event remembers); 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/12-combat/reference/rust/src/engine/event.rs b/course/12-combat/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..5dcb673 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/event.rs @@ -0,0 +1,76 @@ +//! What happened, in order. The engine records an event each time it changes the game. +//! The world also keeps the nesting depth each event was recorded at (`World::log_depths`). + +use super::card::TriggerOn; +use super::world::{EntityId, PlayerId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + /// `id` entered the board. `by` is the minion whose triggered ability put it there (the + /// trigger resolving when it entered), or `None` for a spell, a play or a summon from + /// outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log + /// doesn't show `by`. + Summoned { + id: EntityId, + by: Option, + }, + Damaged { + target: EntityId, + amount: i32, + }, + Triggered { + source: EntityId, + on: TriggerOn, + }, + Drew { + player: PlayerId, + id: EntityId, + }, + Fatigue { + player: PlayerId, + }, + ArmorGained { + player: PlayerId, + amount: i32, + }, + /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows + /// shows the full amount. + ArmorLost { + 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. + /// `before` is the nearest minion to its right that did not die in the same step, if any: + /// its Deathrattle summons immediately left of that minion, or at the right end. It is a + /// handle because a board index goes stale when a minion further left dies in the same step. + Destroyed { + id: EntityId, + before: Option, + }, + DeathPhase { + round: u32, + }, + /// A minion was played from hand (it has just entered the board). Its Battlecry answers this. + Played { + id: EntityId, + }, + /// Module 12: `player`'s turn ended. Their minions' end of turn triggers answer this. + TurnEnded { + player: PlayerId, + }, + /// 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/12-combat/reference/rust/src/engine/mod.rs b/course/12-combat/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..5331c8b --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/mod.rs @@ -0,0 +1,68 @@ +//! 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. A second interceptor, replacement: the +//! steps that rewrite a Damage before it lands (a redirect, then Armor). +//! +//! 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, and +//! offers only results that are well-formed. A Sequence is bounded: a trigger never +//! nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error. +//! +//! Module 12: combat (`combat`: who may attack whom; Taunt) and the end of a turn. +//! A Damage action deals each target its own amount, and a Queue orders its entries by +//! priority before order of play, so the defender's triggers go first. +//! +//! 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. +//! Module 11: conditions (`X ON Z IS P`), checked for each object when a rule is used. +//! +//! 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 combat; +mod death; +mod effect; +mod event; +pub mod puzzle; +mod queue; +mod spellgrid; +mod world; + +pub use assemble::{assemble, register_assembled, AssembleError, AssembleFailure, Half}; +pub use aura::aura_update_other; +pub use card::{ + well_formed, Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, + RedirectFrom, RedirectTo, Stacking, Target, Trigger, TriggerOn, +}; +pub use combat::{attack_error, TAUNT}; +pub use event::Event; +pub use queue::{ + hits, run_sequence, summon, Action, Ctx, DamageStep, EndOfPhaseStep, Hit, Priority, + DAMAGE_STEPS, DEATH_PHASE_LIMIT, DEFENDER, END_OF_PHASE, NORMAL, +}; +pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid}; +pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/12-combat/reference/rust/src/engine/puzzle/condition.rs b/course/12-combat/reference/rust/src/engine/puzzle/condition.rs new file mode 100644 index 0000000..4e7df32 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/condition.rs @@ -0,0 +1,21 @@ +//! Conditions (Module 11): `X ON Z IS P` gives an X the property P only while it is on a Z. +//! +//! The parser reads a condition into the rule (`Rule::conditions`); the engine never +//! decides it there. A condition is evaluated for one object, against the board as +//! it is, every time a rule is about to be used: by `Level::has_property` (and so by +//! movement, the hazards and the win check), and by conversion. What a condition +//! word means is a `ConditionCheck` that content provides, registered by word, as +//! movement responses and hazards are. The engine knows only that a word with role +//! `Condition` may stand between the subjects and `IS`. + +use std::collections::HashMap; + +use super::grid::ObjectId; +use super::level::Level; + +/// Content's meaning for one condition word: does the condition, with these nouns +/// (in the order they are written), hold for object `id` on the board as it is now? +pub type ConditionCheck = fn(&Level, ObjectId, &[String]) -> bool; + +/// Content's condition checks, by condition word: `{"ON": on}`. +pub type ConditionChecks = HashMap; diff --git a/course/12-combat/reference/rust/src/engine/puzzle/grid.rs b/course/12-combat/reference/rust/src/engine/puzzle/grid.rs new file mode 100644 index 0000000..79d531a --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/src/engine/puzzle/level.rs b/course/12-combat/reference/rust/src/engine/puzzle/level.rs new file mode 100644 index 0000000..616c637 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/level.rs @@ -0,0 +1,382 @@ +//! 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. +//! +//! Undo (Module 9): before each move or turn the level saves the board (every +//! object's id, kind and position, oldest first, and the id counter) and `won`. +//! The rules are not saved: undo restores the board and reparses. +//! +//! 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. SpellGrid doesn't offer undo: Part I has state the level's +//! snapshot doesn't cover. +//! +//! Module 11: a rule may carry conditions (`X ON Z IS P`). They are not +//! decided when the rules are compiled. Every question about an object +//! (`has_property`, conversion) asks content's `ConditionCheck`s against the board +//! as it is at that moment, and uses only the rules whose conditions hold for that +//! object. + +use std::collections::BTreeSet; + +use super::condition::ConditionChecks; +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, Role, WordDef}; + +#[derive(Debug, Clone)] +pub struct Level { + board: Board, + lexicon: Lexicon, + responses: Vec, + hazards: Vec, + conditions: ConditionChecks, + rules: Vec, + active: Vec, + won: bool, + /// What `undo` puts back: one entry per move or turn, the latest last (Module 9). + history: Vec, +} + +/// The level before a move or a turn: the whole board (its objects, oldest first, +/// and its id counter) and `won`. +#[derive(Debug, Clone)] +struct Snapshot { + board: Board, + 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, content's movement + /// responses and hazards, each in the order they are to run, and content's + /// condition checks, by word. Panics if a word with role `Condition` has no check. + pub fn new( + def: &LevelDef, + words: &[WordDef], + responses: Vec, + hazards: Vec, + conditions: ConditionChecks, + ) -> Level { + for w in words { + if w.role == Role::Condition && !conditions.contains_key(&w.name.to_uppercase()) { + panic!("the condition word {} has no condition check", w.name); + } + } + let mut level = Level { + board: Board::new(def.width, def.height), + lexicon: Lexicon::new(words), + responses, + hazards, + conditions, + rules: vec![], + active: vec![], + won: false, + history: vec![], + }; + for o in &def.objects { + level.board.place(Kind::parse(&o.kind), o.x, o.y); + } + level.reparse(); + level + } + + pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { + let id = self.board.place(Kind::parse(kind), x, y); + self.history.clear(); + 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 that applies to it + /// (`applies_to`) gives it the property and no active negated rule that applies + /// to it takes it away. A destroyed object has no properties. + pub fn has_property(&self, id: ObjectId, property: &str) -> bool { + if self.board.find(id).is_none() { + return false; + } + let about_it: Vec<&Rule> = self + .active + .iter() + .filter(|r| r.complement == property && self.applies_to(r, id)) + .collect(); + about_it.iter().any(|r| !r.negated) && !about_it.iter().any(|r| r.negated) + } + + /// Does the noun `word`, as the subject of a rule, name object `id`? + pub fn names(&self, word: &str, id: ObjectId) -> bool { + self.lexicon.names(word, &self.board.get(id).kind) + } + + /// Does `rule` apply to object `id` now? Its subject names the object, and each + /// of its conditions holds for the object on the board as it is (Module 11). + fn applies_to(&self, rule: &Rule, id: ObjectId) -> bool { + self.names(&rule.subject, id) + && rule + .conditions + .iter() + .all(|c| (self.conditions[&c.word])(self, id, &c.nouns)) + } + + /// 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.save(); + 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) { + self.save(); + 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(), + } + } + } + + /// Put the level back as it was before the last move or turn (Module 9), and + /// reparse. False, and no change, if there is none; `place` forgets them all. + pub fn undo(&mut self) -> bool { + let Some(last) = self.history.pop() else { + return false; + }; + self.board = last.board; + self.won = last.won; + self.reparse(); + true + } + + fn save(&mut self) { + let snapshot = Snapshot { + board: self.board.clone(), + won: self.won, + }; + self.history.push(snapshot); + } + + /// 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. + /// + /// An object steps at most once in a movement phase. A `YOU` object that an + /// earlier move already carried along (pushed, or moved by any other response) + /// doesn't start a move of its own, and no later move can make it step again. + 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(); + let mut stepped = BTreeSet::new(); + for (_, id) in you { + if self.board.find(id).is_none() { + continue; // destroyed by an earlier move + } + if stepped.contains(&id) { + continue; // already moved, by an earlier move + } + let mut planner = Planner::new(self, dir, &stepped); + if planner.try_move(id) { + let plan = planner.into_plan(); + self.commit(&plan, dir, destroy); + stepped.extend(plan.moving.difference(&plan.destroyed)); + } + } + } + + /// 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`. + /// + /// Negation is settled per object, as in `has_property`: an object doesn't + /// become a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + /// conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an 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. Only the rules that + /// apply to the object are used: their conditions are checked on that board too. + 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.applies_to(r, o.id)) + .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()) { + let negated = |n: &&Rule| n.negated && n.complement == r.complement; + if about_it.iter().any(negated) { + continue; + } + 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/12-combat/reference/rust/src/engine/puzzle/mod.rs b/course/12-combat/reference/rust/src/engine/puzzle/mod.rs new file mode 100644 index 0000000..55eb699 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/mod.rs @@ -0,0 +1,47 @@ +//! 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. +//! +//! Module 11: conditions. A word with role `Condition` may stand between a rule's +//! subjects and `IS` (`X ON Z IS P`); the rule carries it (`Rule::conditions`). +//! What it means is a `ConditionCheck` content registers by word. `Level` asks the +//! checks every time it uses a rule, against the board as it is then. + +mod condition; +mod grid; +mod level; +mod movement; +mod parse; +mod resolve; +mod turn; +mod word; + +pub use condition::{ConditionCheck, ConditionChecks}; +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, Condition, 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/12-combat/reference/rust/src/engine/puzzle/movement.rs b/course/12-combat/reference/rust/src/engine/puzzle/movement.rs new file mode 100644 index 0000000..565e4e4 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/movement.rs @@ -0,0 +1,144 @@ +//! 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. `stepped` holds the objects that have +/// already stepped in this movement phase, in an earlier committed plan: an object +/// steps at most once per phase, so none of them steps again. +pub struct Planner<'a> { + level: &'a Level, + dir: Dir, + stepped: &'a BTreeSet, + plan: Plan, +} + +impl<'a> Planner<'a> { + pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet) -> Planner<'a> { + Planner { + level, + dir, + stepped, + 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. An + /// object that has already stepped in this movement phase doesn't step again, and + /// doesn't hold the mover back either: the answer is yes, and nothing is added, so + /// objects that began the phase stacked on one tile end it stacked. + pub fn try_move(&mut self, id: ObjectId) -> bool { + if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { + return true; + } + if self.stepped.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/12-combat/reference/rust/src/engine/puzzle/parse.rs b/course/12-combat/reference/rust/src/engine/puzzle/parse.rs new file mode 100644 index 0000000..ae688c8 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/parse.rs @@ -0,0 +1,313 @@ +//! 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. +//! +//! Module 11: conditions. `NOUN [AND NOUN]* [CONDITION NOUN [AND NOUN]*] IS ...`: a word +//! whose role is `Condition` may stand between the subjects and `IS`, followed by +//! the nouns it is about. The condition belongs to every subject of the sentence, +//! and AND among its nouns means all of them. A run of nouns before `IS` is the +//! condition's when a condition word stands before it, and then it is not a run of +//! subjects. Stacked text still yields every possible rule: a stack of nouns after +//! the condition word gives one rule per noun, as a stack after `IS` does. +//! +//! The parser only reads; which rules win is the resolution pass (`resolve`), and +//! whether a condition holds is decided for each object when someone asks (`Level`). + +use std::collections::BTreeMap; +use std::fmt; + +use super::grid::{Board, ObjectId}; +use super::word::{lex, Lexicon, Role, Token, AND, IS, NOT, TEXT}; + +/// A condition on a rule (Module 11): the condition word and the nouns after it, in +/// the order they are written. `X ON Y AND Z IS P` has `word: "ON"`, +/// `nouns: ["Y", "Z"]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Condition { + pub word: String, + pub nouns: Vec, +} + +/// One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`, +/// applying to an object only while each of its `conditions` holds for that object +/// (none for an unconditional rule). `text` is the text objects that spell it +/// (subject, condition word and nouns if any, `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 conditions: Vec, + 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, complement and conditions, opposite sign: `X IS NOT Y` negates + /// `X IS Y`, and `X ON Z IS NOT Y` negates `X ON Z IS Y` (Module 11), but not `X IS Y`. + pub fn negates(&self, other: &Rule) -> bool { + self.negated + && !other.negated + && self.subject == other.subject + && self.complement == other.complement + && self.same_conditions(other) + } + + /// The same conditions: the same words, each with the same nouns in any order. + pub fn same_conditions(&self, other: &Rule) -> bool { + let key = |r: &Rule| -> Vec<(String, Vec)> { + r.conditions + .iter() + .map(|c| { + let mut nouns = c.nouns.clone(); + nouns.sort(); + (c.word.clone(), nouns) + }) + .collect() + }; + key(self) == key(other) + } +} + +/// The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`, `"X ON Y AND Z IS P"`. +impl fmt::Display for Rule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.subject)?; + for c in &self.conditions { + write!(f, " {} {}", c.word, c.nouns.join(&format!(" {AND} ")))?; + } + let not = if self.negated { " NOT" } else { "" }; + write!(f, " {IS}{not} {}", 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, + conditions: vec![], + 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 complements = complements(&tiles, (x + dx, y + dy), (dx, dy)); + for reading in before_is(&tiles, (x - dx, y - dy), (-dx, -dy)) { + for s in &reading.subjects { + for c in &complements { + let mut text = vec![s.id]; + text.extend(&reading.text); + text.push(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(), + conditions: reading.conditions.clone(), + 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) +} + +/// What stands before an `IS` (Module 11): the subjects, with the condition on them +/// if there is one. +struct Reading<'a> { + subjects: Vec<&'a Token>, + conditions: Vec, + /// The condition word and its nouns, in reading order (empty without a condition). + text: Vec, +} + +/// A run of words walking backwards, and the tile the walk stopped on, before the +/// run's first word. +struct Run<'a> { + tokens: Vec<&'a Token>, + stop: (i32, i32), +} + +fn has_condition(tiles: &Tiles, p: (i32, i32)) -> bool { + words_at(tiles, p).iter().any(|t| t.role == Role::Condition) +} + +/// Every way to read the words before an `IS`, walking backwards from `at`. +/// +/// - The run of subjects, without a condition, unless a condition word stands +/// before the run: then those nouns are the condition's, not subjects. +/// - For every run of nouns that has a condition word before it, the run of +/// subjects before that word, with the condition. A stack among the condition's +/// nouns gives one reading per noun (`noun_runs`). +fn before_is(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec> { + let mut readings = vec![]; + let run = subjects(tiles, at, step); + if !has_condition(tiles, run.stop) { + readings.push(Reading { + subjects: run.tokens, + conditions: vec![], + text: vec![], + }); + } + for nouns in noun_runs(tiles, at, step) { + let in_order: Vec<&Token> = nouns.tokens.iter().rev().copied().collect(); + let words = words_at(tiles, nouns.stop).iter(); + for word in words.filter(|t| t.role == Role::Condition) { + let mut text = vec![word.id]; + text.extend(in_order.iter().map(|t| t.id)); + readings.push(Reading { + subjects: subjects(tiles, ahead(nouns.stop, step), step).tokens, + conditions: vec![Condition { + word: word.word.clone(), + nouns: in_order.iter().map(|t| t.word.clone()).collect(), + }], + text, + }); + } + } + readings +} + +/// 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)) -> Run<'_> { + let before = ahead(at, step); + if holds(tiles, before, NOT) { + return Run { + tokens: vec![], + stop: before, + }; + } + 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) { + let rest = subjects(tiles, ahead(before, step), step); + if !rest.tokens.is_empty() { + found.extend(rest.tokens); + return Run { + tokens: found, + stop: rest.stop, + }; + } + } + Run { + tokens: found, + stop: before, + } +} + +/// Every run of nouns walking backwards from `at`, one noun from each tile, joined +/// by AND; each with the tile before its first noun. Runs that end early, at an +/// AND, are included: a condition word stacked on that AND starts a reading. A +/// `NOT` before a noun ends every run through it. +fn noun_runs(tiles: &Tiles, at: (i32, i32), step: (i32, i32)) -> Vec> { + let before = ahead(at, step); + if holds(tiles, before, NOT) { + return vec![]; + } + let mut runs = vec![]; + for noun in words_at(tiles, at).iter().filter(|t| t.role == Role::Noun) { + runs.push(Run { + tokens: vec![noun], + stop: before, + }); + if !holds(tiles, before, AND) { + continue; + } + for rest in noun_runs(tiles, ahead(before, step), step) { + let mut tokens = vec![noun]; + tokens.extend(rest.tokens); + runs.push(Run { + tokens, + stop: rest.stop, + }); + } + } + runs +} + +/// 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/12-combat/reference/rust/src/engine/puzzle/resolve.rs b/course/12-combat/reference/rust/src/engine/puzzle/resolve.rs new file mode 100644 index 0000000..0abc52c --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/resolve.rs @@ -0,0 +1,39 @@ +//! 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`. +//! +//! Module 11: both steps pair rules only when their conditions are the same. +//! `X ON Z IS NOT P` cancels `X ON Z IS P` here, but not `X IS P`: +//! which of those wins depends on where each X stands, so it is settled per +//! object when someone asks (`Level::has_property`, and conversion), among the +//! rules whose conditions hold for that object. + +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 = |r: &Rule| { + survives_negation.iter().any(|l| { + !l.negated && l.is_identity() && l.subject == r.subject && l.same_conditions(r) + }) + }; + survives_negation + .iter() + .filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(r)) + .map(|&r| r.clone()) + .collect() +} diff --git a/course/12-combat/reference/rust/src/engine/puzzle/turn.rs b/course/12-combat/reference/rust/src/engine/puzzle/turn.rs new file mode 100644 index 0000000..a71b0f4 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/src/engine/puzzle/word.rs b/course/12-combat/reference/rust/src/engine/puzzle/word.rs new file mode 100644 index 0000000..ea8dc07 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/puzzle/word.rs @@ -0,0 +1,135 @@ +//! 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. +//! +//! Module 11: a fourth role, `Condition`. The engine knows only where a condition +//! word may stand in a sentence (between the subjects and `IS`); what it means is a +//! check content provides (`ConditionCheck`). + +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, + Condition, +} + +/// 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/12-combat/reference/rust/src/engine/queue.rs b/course/12-combat/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..315e094 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/queue.rs @@ -0,0 +1,480 @@ +//! 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 4, second interceptor: replacement. Before a Damage changes the world, +//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes +//! who takes it, then Armor takes what it can of damage to a hero. +//! +//! 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 5, robustness: once a test can write any card as data, one card can make a +//! Sequence endless. A trigger is never queued while that same trigger of that same +//! minion is resolving (the nesting guard in `react`), and a Sequence that needs more +//! than `DEATH_PHASE_LIMIT` Death Phases is an error. +//! +//! 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. +//! +//! Module 12: combat and the end of a turn. An attack is one Damage action whose two +//! targets take different amounts (`Hit`), and the triggers answering the defender's +//! Damage Event queue before the attacker's, whatever their order of play. That +//! exception is data on the queue entry, a priority: `react` orders its Queue by +//! priority first and keeps the order it was filled in (order of play) within one +//! priority. Ending a turn raises an event that the turn's player's end of turn +//! triggers answer. + +use super::assemble::assemble_from_pool; +use super::aura::{activations, aura_update_other, redirect_for}; +use super::card::{Effect, PoolFilter, TriggerOn}; +use super::combat::attack_error; +use super::death::death_creation_step; +use super::effect::actions; +use super::event::Event; +use super::world::{EntityId, PlayerId, World, Zone}; + +/// How early the triggers answering an event queue (Module 12). A Queue resolves its +/// entries highest priority first and, within one priority, in the order it was filled: +/// for each event in order of play, each listener in order of play. +pub type Priority = i32; + +/// The priority of every entry, except where a rule says otherwise. +pub const NORMAL: Priority = 0; + +/// Combat: the triggers answering the defender's Damage Event go first. +pub const DEFENDER: Priority = 1; + +/// One target of a Damage action, the amount it takes, and the priority of the triggers +/// its Damage Event queues. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Hit { + pub target: EntityId, + pub amount: i32, + pub priority: Priority, +} + +/// The same amount to each target, at normal priority: what area damage and a spell deal. +pub fn hits(targets: &[EntityId], amount: i32) -> Vec { + targets + .iter() + .map(|&target| Hit { + target, + amount, + priority: NORMAL, + }) + .collect() +} + +/// Something the engine can do. Card effects return these instead of changing the world. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Deal each hit's amount to its target at once: all damage first, then the reactions. + Damage { + hits: Vec, + }, + 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, + }, + /// Combat (Module 12): `attacker` and `defender` deal their Attack to each other at once. + Attack { + attacker: EntityId, + defender: EntityId, + }, + /// The end of `player`'s turn (Module 12): their minions' end of turn triggers answer it. + EndTurn { + player: PlayerId, + }, +} + +/// 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]; + +/// A step that may rewrite one Damage to one target before the world changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DamageStep { + /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that + /// aura's minion instead (the oldest one's, if several). + Redirect, + /// Damage to a hero lowers its owner's Armor first, then Health. + Armor, +} + +/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is +/// never offered to a redirect again. A redirect comes first: Armor protects only +/// the hero that actually takes the damage. +pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor]; + +/// 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 where it stood: immediately left of the minion its Death Event remembers. + 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) + } +} + +/// The most Death Phases one Sequence may have. One more is an error, not a cut-off: +/// a Sequence that stopped early would resolve differently without saying so. +pub const DEATH_PHASE_LIMIT: u32 = 64; + +/// 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; + // Between Death Phases no trigger is resolving, so the panic leaves no block open. + assert!( + round <= DEATH_PHASE_LIMIT, + "Death Phase limit: more than {DEATH_PHASE_LIMIT} Death Phases in one Sequence" + ); + 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 hits } => { + // Area damage: every Damage Event is created, in order of play, before any reaction. + hits.sort_by_key(|hit| world.order_of_play(hit.target)); + let mut events = Vec::new(); + for hit in &hits { + events.push(apply_damage(world, hit.target, hit.amount)); + } + let priorities: Vec = hits.iter().map(|hit| hit.priority).collect(); + react(world, &events, &priorities); + } + Action::Attack { attacker, defender } => { + if let Some(error) = attack_error(world, attacker, defender) { + panic!("{error}"); + } + // Both amounts are read before either is dealt: the damage is simultaneous. A + // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion. + let mut attack = vec![Hit { + target: defender, + amount: world.attack(attacker), + priority: DEFENDER, + }]; + let counter = world.attack(defender); + if counter > 0 { + attack.push(Hit { + target: attacker, + amount: counter, + priority: NORMAL, + }); + } + perform(world, Action::Damage { hits: attack }); + } + Action::EndTurn { player } => { + let ended = Event::TurnEnded { player }; + world.record(ended.clone()); + react(world, &[ended], &[]); + } + 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); + } + } +} + +/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it. +/// Returns its Damage Event, which names whoever took it. +fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event { + let mut taker = target; + let mut absorbed = 0; + for step in DAMAGE_STEPS { + match step { + DamageStep::Redirect => { + if world.is_hero(taker) { + taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker); + } + } + DamageStep::Armor => { + if world.is_hero(taker) { + let player = world.owner_of(taker); + absorbed = world.armor(player).min(amount); + if absorbed > 0 { + world.lose_armor(player, absorbed); + } + } + } + } + } + world.damage(taker, amount, absorbed); + Event::Damaged { + target: taker, + amount, + } +} + +/// A Phase: queue every trigger that responds to `events`, then resolve them one by one. +/// `priorities[i]` is the priority of the entries answering `events[i]` (`NORMAL` if absent). +fn react(world: &mut World, events: &[Event], priorities: &[Priority]) { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + // The nesting guard: a trigger that is resolving right now is not queued again, so + // a trigger is never nested inside itself. + let mut queue = Vec::new(); + for (e, event) in events.iter().enumerate() { + let priority = priorities.get(e).copied().unwrap_or(NORMAL); + for listener in listeners(world, event) { + for (index, trigger) in world + .card(world.card_name(listener)) + .triggers + .iter() + .enumerate() + { + if responds(world, trigger.on, listener, event) + && !world.is_resolving(listener, index) + { + queue.push((listener, index, trigger.clone(), event, priority)); + } + } + } + } + // Priority first; the sort is stable, so entries of one priority keep the order above. + queue.sort_by_key(|entry| std::cmp::Reverse(entry.4)); + // The Queue is now locked: nothing that happens below adds to it. + for (source, index, 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, before } if id == source => { + Some(position_left_of(world, world.owner_of(source), before)) + } + _ => world.board_index(source), + }; + // The trigger's consequences are one level deeper in the log tree. + world.open_block(source, index); + let owner = world.owner_of(source); + run_effects(world, &trigger.effects, owner, Some(source), None, position); + world.close_block(); + } + } +} + +/// The board index, right now, immediately left of `before` on `owner`'s board; the +/// right end if `before` is `None` or no longer there. A Death Event remembers the +/// minion, not an index, so this still holds after other minions have left or arrived. +fn position_left_of(world: &World, owner: PlayerId, before: Option) -> usize { + let board = world.zone(owner, Zone::Board); + before + .and_then(|before| board.iter().position(|&id| id == before)) + .unwrap_or(board.len()) +} + +/// 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; + } + if let Event::TurnEnded { player } = *event { + return on == TriggerOn::EndOfTurn && world.owner_of(listener) == player; + } + 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/12-combat/reference/rust/src/engine/spellgrid.rs b/course/12-combat/reference/rust/src/engine/spellgrid.rs new file mode 100644 index 0000000..4e5c83b --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/spellgrid.rs @@ -0,0 +1,418 @@ +//! 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 four 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. +//! 4. Summoned minions enter the grid. In the same place, before the dead leave: a +//! minion that a minion's ability summoned (`Summoned` events with a `by`), whose card +//! has a noun, becomes an object of that noun on its summoner's tile. One summoned by +//! a spell, or with no noun, is in play but not on the grid. +//! +//! 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, Effect, Stacking, Target, TriggerOn}; +use super::event::Event; +use super::puzzle::{ + ConditionChecks, 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`. +/// +/// Module 11: a rule with a condition gives no aura. A condition holds or not for one +/// object on the grid, and a trigger word names no object (the course's choice). +pub fn rule_auras(rules: &[Rule], words: &SeamWords) -> Vec { + let mut auras = Vec::new(); + for rule in rules { + if !rule.conditions.is_empty() { + continue; + } + 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, + conditions: ConditionChecks, + seam_words: SeamWords, + ) -> SpellGrid { + let mut level = Level::new(def, words, responses, hazards, conditions); + 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); + } + + /// Why `cast(card, target)` can't be done now, or `Ok` if it can: the card must be a + /// spell in player 0's hand, and a spell that hits a chosen minion needs a target + /// minion on the grid. Nothing changes either way. + pub fn check_cast(&self, card: &str, target: Option) -> Result<(), String> { + self.side.check_hand(card, CardType::Spell)?; + let chooses = self.side.world.card(card).on_cast.iter().any(|e| { + matches!( + e, + Effect::DealDamage { + target: Target::Chosen, + .. + } | Effect::GainAttack { + target: Target::Chosen, + .. + } | Effect::Destroy { + target: Target::Chosen + } + ) + }); + match target { + None if chooses => Err(format!("\"{card}\" needs a target minion")), + None => Ok(()), + Some(id) => { + // Every id in `entities` was issued by the board, so `find` may ask about it. + let card = self.side.entities.contains_key(&id); + if card && self.level.board().find(id).is_some() { + Ok(()) + } else { + Err(format!("{id} is not a minion on the grid")) + } + } + } + } + + /// Why `play(card, x, y)` can't be done now, or `Ok` if it can. + pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> { + self.side.check_hand(card, CardType::Minion)?; + if self.side.world.card(card).def.noun.is_none() { + return Err(format!( + "\"{card}\" has no noun: it can't stand on the grid" + )); + } + if !self.level.board().in_bounds(x, y) { + return Err(format!("({x}, {y}) is off the grid")); + } + if self.level.board().at(x, y).next().is_some() { + return Err(format!("({x}, {y}) is not empty")); + } + Ok(()) + } + + /// Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. + /// Panics if `check_cast` says no. + pub fn cast(&mut self, card: &str, target: Option) { + if let Err(problem) = self.check_cast(card, target) { + panic!("{problem}"); + } + let in_hand = self.side.in_hand(card).expect("checked"); + 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. Panics if `check_play` says no. + pub fn play(&mut self, card: &str, x: i32, y: i32) -> ObjectId { + if let Err(problem) = self.check_play(card, x, y) { + panic!("{problem}"); + } + let in_hand = self.side.in_hand(card).expect("checked"); + 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 in_hand(&self, card: &str) -> Option { + self.world + .zone(0, Zone::Hand) + .iter() + .copied() + .find(|&id| self.world.card_name(id) == card) + } + + fn check_hand(&self, card: &str, card_type: CardType) -> Result<(), String> { + if self.in_hand(card).is_none() { + return Err(format!("\"{card}\" is not in the hand")); + } + if self.world.card(card).def.card_type != card_type { + let kind = match card_type { + CardType::Minion => "minion", + CardType::Spell => "spell", + }; + return Err(format!("\"{card}\" is not a {kind}")); + } + Ok(()) + } + + /// 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 seams 3 and 4: 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 yet: 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); + } + } + } + // Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are + // still on the grid here, so a Deathrattle's summoner is found where it died; a + // minion summoned earlier in the list is already placed, so it can be a summoner too. + for event in &events { + let Event::Summoned { id, by: Some(by) } = *event else { + continue; + }; + let card = self.world.card_name(id).to_string(); + if self.ids.contains_key(&id) || self.world.card(&card).def.noun.is_none() { + continue; + } + let tile = self + .ids + .get(&by) + .and_then(|&object| level.board().find(object)) + .map(|o| (o.x, o.y)); + if let Some((x, y)) = tile { + let object = level.board_mut().place(self.noun_of(&card), x, y); + self.link(id, object); + } + } + 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/12-combat/reference/rust/src/engine/world.rs b/course/12-combat/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..4e100d6 --- /dev/null +++ b/course/12-combat/reference/rust/src/engine/world.rs @@ -0,0 +1,456 @@ +//! 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 minion has it, and that minion's owner. An aura from outside +/// the cards (Module 10) has no minion (`source` is `None`), and is in force once for +/// each player. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActiveAura { + pub owner: PlayerId, + pub source: Option, + 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 nesting depth at which each event in `log` was recorded, index for index. + depths: Vec, + /// The triggers resolving right now, one inside another, outermost first: each is a + /// minion and the index of the trigger in its card's `triggers`. How many there are + /// is the nesting depth of the log tree. + resolving: Vec<(EntityId, usize)>, + /// 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(), + depths: Vec::new(), + resolving: 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); + // The summoner is the minion whose trigger is resolving (the innermost one), if any. + let by = self.resolving.last().map(|&(source, _)| source); + self.record(Event::Summoned { id, by }); + } + } + + 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 + } + + /// The nesting depth of each event in `log()`, index for index: 0 for the outermost + /// action's own events. + pub fn log_depths(&self) -> &[u32] { + &self.depths + } + + // ---- Changes: each one records its event ---------------------------- + + pub fn record(&mut self, event: Event) { + self.log.push(event); + self.depths.push(self.resolving.len() as u32); + } + + /// Trigger number `trigger` of `source` starts resolving: the events it causes are + /// recorded one level deeper. + pub fn open_block(&mut self, source: EntityId, trigger: usize) { + self.resolving.push((source, trigger)); + } + + /// The trigger that last opened a block has finished resolving. + pub fn close_block(&mut self) { + self.resolving.pop(); + } + + /// Is trigger number `trigger` of `source` resolving right now (at any depth)? + pub fn is_resolving(&self, source: EntityId, trigger: usize) -> bool { + self.resolving.contains(&(source, trigger)) + } + + /// 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(); + self.depths.clear(); + } + + /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor + /// (see `lose_armor`): Health drops by the rest. The event shows the full amount. + pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) { + self.entity_mut(target).health -= amount - absorbed; + self.record(Event::Damaged { target, amount }); + } + + /// `player`'s hero's Armor absorbs `amount` damage. + pub fn lose_armor(&mut self, player: PlayerId, amount: i32) { + self.sides[player as usize].armor -= amount; + self.record(Event::ArmorLost { player, 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, before: Option) { + self.entity_mut(id).pending_destroy = false; + self.move_to(id, Zone::Graveyard); + self.record(Event::Destroyed { id, before }); + } + + 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/12-combat/reference/rust/src/harness.rs b/course/12-combat/reference/rust/src/harness.rs new file mode 100644 index 0000000..6e59881 --- /dev/null +++ b/course/12-combat/reference/rust/src/harness.rs @@ -0,0 +1,555 @@ +//! 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, attack_error, hits, 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 }, + ArmorLost { 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::ArmorLost { player, amount } => GameEvent::ArmorLost { 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, + // The end of a turn shows in the triggers that answer it. + Event::TurnEnded { .. } => return None, + // SpellGrid writes the text on the grid; the course's log doesn't show the request. + Event::TextSpawned { .. } => return None, + }) + } +} + +/// One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. +impl std::fmt::Display for GameEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameEvent::Summoned { id } => write!(f, "Summoned{{id: {id}}}"), + GameEvent::Damaged { target, amount } => { + write!(f, "Damaged{{target: {target}, amount: {amount}}}") + } + GameEvent::Triggered { source, trigger } => { + write!(f, "Triggered{{source: {source}, trigger: {trigger:?}}}") + } + GameEvent::Destroyed { id } => write!(f, "Destroyed{{id: {id}}}"), + GameEvent::DeathPhase { round } => write!(f, "DeathPhase{{round: {round}}}"), + GameEvent::Drew { player, id } => write!(f, "Drew{{player: {player}, id: {id}}}"), + GameEvent::Fatigue { player } => write!(f, "Fatigue{{player: {player}}}"), + GameEvent::ArmorGained { player, amount } => { + write!(f, "ArmorGained{{player: {player}, amount: {amount}}}") + } + GameEvent::ArmorLost { player, amount } => { + write!(f, "ArmorLost{{player: {player}, amount: {amount}}}") + } + GameEvent::StatsChanged { id, attack, health } => { + write!( + f, + "StatsChanged{{id: {id}, attack: {attack}, health: {health}}}" + ) + } + GameEvent::SpellCast { card } => write!(f, "SpellCast{{card: {card:?}}}"), + } + } +} + +/// The log as a tree: one event per line, two spaces of indent per level of nesting. +fn log_tree(world: &World, view: impl Fn(&Event) -> Option) -> String { + world + .log() + .iter() + .zip(world.log_depths()) + .filter_map(|(event, &depth)| { + let shown = view(event)?; + Some(format!("{}{shown}\n", " ".repeat(depth as usize))) + }) + .collect() +} + +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 hits = hits(&[target], amount); + run_sequence(&mut self.world, Action::Damage { hits }); + } + 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(); + } + /// The log as an indented tree (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(&self.world, GameEvent::from_engine) + } + + // ---- 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)); + } + + // ---- Module 12 ---- + + /// `attacker` attacks `defender`, as one Sequence. If the attack is illegal, nothing + /// happens and the error is `can_attack`'s reason. + pub fn order_attack(&mut self, attacker: Id, defender: Id) -> Result<(), String> { + if let Some(reason) = self.can_attack(attacker, defender) { + return Err(reason); + } + run_sequence(&mut self.world, Action::Attack { attacker, defender }); + Ok(()) + } + + /// Why `attacker` may not attack `defender` now, or `None` if it may. + pub fn can_attack(&self, attacker: Id, defender: Id) -> Option { + attack_error(&self.world, attacker, defender) + } + + /// End `player`'s turn, as one Sequence: their minions' end of turn triggers resolve. + pub fn end_turn(&mut self, player: Player) { + run_sequence(&mut self.world, Action::EndTurn { player }); + } +} + +// ======== 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(), + content::conditions(), + ), + } +} + +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"`, `"BABA ON KEY IS WIN"`) 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 + } + + /// The kinds on tile (x, y), as `kinds_at`, but oldest object first. + pub fn stack_at(&self, x: i32, y: i32) -> Vec { + let board = self.level.board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } + + // ---- 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() + } + + /// Put the level back as it was before the last `step` or `move_dir`. False, and + /// no change, if there is none; repeated calls walk further back. + pub fn undo(&mut self) -> bool { + self.level.undo() + } +} + +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::conditions(), + 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 stack_at(&self, x: i32, y: i32) -> Vec { + let board = self.grid.level().board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } + 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() + } + /// Not supported: Part I has state (health, hands, the log) that the level's + /// snapshot doesn't cover. Panics. + pub fn undo(&mut self) -> bool { + panic!("undo is not supported in SpellGrid"); + } + + // ---- 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() + } + /// The course's view of an engine event, with every entity id as a grid id. + fn grid_event(&self, event: &Event) -> Option { + let id = |entity: Id| self.grid.id_of(entity); + Some(match GameEvent::from_engine(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, + }) + } + /// Part I's log, with grid ids. + pub fn log(&self) -> Vec { + let log = self.grid.world().log(); + log.iter().filter_map(|e| self.grid_event(e)).collect() + } + pub fn clear_log(&mut self) { + self.grid.world_mut().clear_log(); + } + /// Part I's log as an indented tree, with grid ids (see course/harness.md). + pub fn log_tree(&self) -> String { + log_tree(self.grid.world(), |e| self.grid_event(e)) + } + /// 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)); + } + /// For the command line: why `cast` would panic, or `Ok` if it wouldn't. + pub fn check_cast(&self, card: &str, target: Option) -> Result<(), String> { + self.grid.check_cast(card, target) + } + /// For the command line: why `play` would panic, or `Ok` if it wouldn't. + pub fn check_play(&self, card: &str, x: i32, y: i32) -> Result<(), String> { + self.grid.check_play(card, x, y) + } + /// 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/12-combat/reference/rust/src/lib.rs b/course/12-combat/reference/rust/src/lib.rs new file mode 100644 index 0000000..0f7aca6 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/rust/tests/cli.rs b/course/12-combat/reference/rust/tests/cli.rs new file mode 100644 index 0000000..cf8d0f7 --- /dev/null +++ b/course/12-combat/reference/rust/tests/cli.rs @@ -0,0 +1,113 @@ +// 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_prints_the_log_tree_of_each_command() { + let level = include_str!("../levels/l1-double-tap.json"); + let (_, out) = play(level, &["w", "w", "c Whirlwind", "q"]); + // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. + assert_eq!(out.matches("Log:\n").count(), 1, "{out}"); + let tree = [ + "Log:", + "SpellCast{card: \"Whirlwind\"}", + "Damaged{target: 32, amount: 1}", + "Damaged{target: 33, amount: 1}", + "Destroyed{id: 32}", + "DeathPhase{round: 1}", + "Triggered{source: 32, trigger: \"deathrattle\"}", + " Damaged{target: 33, amount: 1}", + "Triggered{source: 32, trigger: \"deathrattle\"}", + " Damaged{target: 33, amount: 1}", + "Destroyed{id: 33}", + "DeathPhase{round: 2}", + "", + ]; + assert!(out.contains(&tree.join("\n")), "{out}"); +} + +#[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:")); +} + +// The two reference CLIs print the same text for the same input (tools/diff-tracks.mjs checks it). +#[test] +fn cli_prints_card_names_as_typed() { + let level = include_str!("../levels/l3-simultaneous-paradox.json"); + let (_, out) = play(level, &["c a\"b", "q"]); + assert!(out.contains("\"a\"b\" is not in the hand"), "{out}"); +} + +#[test] +fn cli_numbers_are_plain_digits() { + // "-1" is not a coordinate, so it is part of the card's name. + let level = include_str!("../levels/l1-double-tap.json"); + let (_, out) = play(level, &["c Whirlwind -1 0", "q"]); + assert!( + out.contains("\"Whirlwind -1 0\" is not in the hand"), + "{out}" + ); + // "+2" is not a number, so the chooser takes the first option. + let level = include_str!("../levels/l2-spliced-corpse.json"); + let (_, out) = play(level, &["c Graft", "+2", "1", "1", "q"]); + assert!(out.contains("Hand: Clay Golem (Assembled)"), "{out}"); +} diff --git a/course/12-combat/reference/ts/.course.json b/course/12-combat/reference/ts/.course.json new file mode 100644 index 0000000..6b90053 --- /dev/null +++ b/course/12-combat/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content", "cards"], "module": "12" } diff --git a/course/12-combat/reference/ts/.gitignore b/course/12-combat/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/12-combat/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/12-combat/reference/ts/README.md b/course/12-combat/reference/ts/README.md new file mode 100644 index 0000000..28f7ccd --- /dev/null +++ b/course/12-combat/reference/ts/README.md @@ -0,0 +1,14 @@ +# Module 12 reference engine (TypeScript) + +The course's own engine as of the end of Module 12: Module 11's engine (Part I's card engine, Part II's grid engine with conditions, and SpellGrid joining them), with combat and the end of a turn in Part I. Every test from Modules 1–11 still passes. Module 11's reference was the starting point, so a diff between the two shows what this module adds. `course/11-conditions/reference/ts/README.md` and the READMEs it points to describe everything else. + +- `cards/` and `levels/` (at the package root; not in this directory): staged by `tools/verify-references.mjs` as for Module 11, plus `course/12-combat/cards/` (`Test Bell`). +- Part I (`src/engine/`): + - `combat.ts` (new): `attackError(world, attacker, defender)`, the legality rules (a minion in play attacks an enemy character in play, with more than 0 Attack, and a Taunt minion if the defender's side has one), and `TAUNT`. + - `card.ts`: `Card.keywords`, compiled from keyword clauses. `"endOfTurn"` now fires. + - `event.ts`: `{ type: "TurnEnded", player }`, which end of turn triggers answer. The course's log doesn't show it. + - `queue.ts`: the `damage` action carries `hits`, each a target, an amount and a `Priority` (`hits(targets, amount)` builds the usual one-amount list). `react` takes the priority of each event's entries and sorts its Queue by priority, highest first, keeping the order it was filled in within one priority. The `attack` action builds one Damage action: the attacker's Attack to the defender at priority `DEFENDER`, and the defender's Attack (if more than 0) to the attacker at `NORMAL`. The `endTurn` action records `TurnEnded` and resolves the triggers that answer it. +- `src/harness.ts`: `orderAttack(attacker, defender)` (throws an `Error` with the reason if the attack is illegal), `canAttack(attacker, defender): string | null` and `endTurn(player)` on `Game`. +- Part II, Part III and `src/cli.ts`: unchanged from Module 11. SpellGrid doesn't attack. `tools/diff-tracks.mjs` uses this module's CLIs by default. + +Run it with the module's tests: `node tools/verify-references.mjs 12` from the course repository. diff --git a/course/12-combat/reference/ts/package-lock.json b/course/12-combat/reference/ts/package-lock.json new file mode 100644 index 0000000..7976b9f --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/package.json b/course/12-combat/reference/ts/package.json new file mode 100644 index 0000000..de262b9 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/src/cli.ts b/course/12-combat/reference/ts/src/cli.ts new file mode 100644 index 0000000..03b853b --- /dev/null +++ b/course/12-combat/reference/ts/src/cli.ts @@ -0,0 +1,138 @@ +// 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. After a command that ran Part I +// Sequences, it prints their events under `Log:` as an indented tree. +// +// 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 coordinate, or a chooser's number: one to nine digits, nothing else (no sign, no point). */ +const NUMBER = /^\d{1,9}$/; + +/** A `c` command: `c [x y]`. */ +function parseCast(args: string[]): { card: string; at?: [number, number] } { + const n = args.length; + if (n >= 3 && NUMBER.test(args[n - 2]) && NUMBER.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() ?? ""; + if (options.includes(answer)) return answer; + return (NUMBER.test(answer) ? options[Number(answer) - 1] : undefined) ?? 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+/); + // The log holds only what this command does. + g.clearLog(); + 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`); + } + const tree = g.logTree(); + if (tree !== "") write(`Log:\n${tree}`); + } +} + +/** 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/12-combat/reference/ts/src/content/conditions.ts b/course/12-combat/reference/ts/src/content/conditions.ts new file mode 100644 index 0000000..64846bc --- /dev/null +++ b/course/12-combat/reference/ts/src/content/conditions.ts @@ -0,0 +1,47 @@ +// Part II: what each condition word means (Module 11). The engine asks these checks +// every time it is about to use a rule with a condition, for one object, against the +// board as it is then. A new condition word is one entry in `words.json`, with role +// "condition", and one check here. + +import type { puzzle } from "../engine"; + +type Level = puzzle.Level; +type ObjectId = puzzle.ObjectId; + +/** + * Can each noun be given an object of its own among `others`, one the noun names? + * `ON ROCK AND ROCK` needs two rocks (the wiki: "AND in the noun2 part requires at + * least as many objects as are mentioned"). Tries every assignment. + */ +function eachHasOne(level: Level, nouns: readonly string[], others: readonly ObjectId[]): boolean { + const taken = new Set(); + const assign = (i: number): boolean => { + if (i === nouns.length) return true; + for (const other of others) { + if (taken.has(other) || !level.names(nouns[i], other)) continue; + taken.add(other); + if (assign(i + 1)) return true; + taken.delete(other); + } + return false; + }; + return assign(0); +} + +/** + * ON: for each noun, another object on the object's tile that the noun names, a + * different one for each noun. Float levels don't matter ("ON ignores FLOAT levels"). + */ +function on(level: Level, id: ObjectId, nouns: readonly string[]): boolean { + const o = level.board.get(id); + const others = level.board + .at(o.x, o.y) + .map((other) => other.id) + .filter((other) => other !== id); + return eachHasOne(level, nouns, others); +} + +/** The condition checks, by condition word. */ +export function conditions(): puzzle.ConditionChecks { + return { ON: on }; +} diff --git a/course/12-combat/reference/ts/src/content/hazards.ts b/course/12-combat/reference/ts/src/content/hazards.ts new file mode 100644 index 0000000..d47f73f --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/src/content/index.ts b/course/12-combat/reference/ts/src/content/index.ts new file mode 100644 index 0000000..698a092 --- /dev/null +++ b/course/12-combat/reference/ts/src/content/index.ts @@ -0,0 +1,50 @@ +// 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 { conditions } from "./conditions"; +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. A condition word (Module 11) also + * needs its check in `conditions.ts`. + */ +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/12-combat/reference/ts/src/content/movement.ts b/course/12-combat/reference/ts/src/content/movement.ts new file mode 100644 index 0000000..7cf78f6 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/src/content/seams.json b/course/12-combat/reference/ts/src/content/seams.json new file mode 100644 index 0000000..656e98b --- /dev/null +++ b/course/12-combat/reference/ts/src/content/seams.json @@ -0,0 +1,4 @@ +{ + "triggerWords": { "DEATHRATTLE": "deathrattle" }, + "multiplierWords": { "DOUBLE": { "times": 2, "stacking": "max" } } +} diff --git a/course/12-combat/reference/ts/src/content/words.json b/course/12-combat/reference/ts/src/content/words.json new file mode 100644 index 0000000..cc2de5f --- /dev/null +++ b/course/12-combat/reference/ts/src/content/words.json @@ -0,0 +1,36 @@ +[ + { "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": "ALL", "role": "noun", "selects": "things" }, + { "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": "CRIER", "role": "noun" }, + { "name": "GOLEM", "role": "noun" }, + { "name": "SHARD", "role": "noun" }, + { "name": "DEATHRATTLE", "role": "noun" }, + { "name": "ON", "role": "condition" }, + { "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/12-combat/reference/ts/src/engine/assemble.ts b/course/12-combat/reference/ts/src/engine/assemble.ts new file mode 100644 index 0000000..1cf8dd4 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/assemble.ts @@ -0,0 +1,117 @@ +// 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, wellFormed, type CardDef, type PoolFilter } from "./card"; +import type { EntityId, PlayerId, World } from "./world"; + +export type Half = "top" | "bottom"; +const HALVES: Half[] = ["top", "bottom"]; + +/** Why an Assemble was refused: the text would not change, or would not be well-formed (see `wellFormed`). */ +export type AssembleFailure = "unchanged" | "illFormed"; + +/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged, or ill-formed. */ +export class AssembleError extends Error { + constructor( + readonly base: string, + readonly donor: string, + readonly half: Half, + readonly reason: AssembleFailure, + ) { + const result = reason === "unchanged" ? "leaves its text unchanged" : "gives ill-formed text"; + super(`assembling "${base}" with the ${half} half of "${donor}" ${result}`); + 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); +/** Two definitions the engine can't tell apart (it ignores `fictional` and `game`). */ +const sameCard = (a: CardDef, b: CardDef): boolean => { + const read = (d: CardDef) => + canonical([d.name, d.type, d.cost, d.attack ?? 0, d.health ?? 0, d.noun ?? null, d.text, d.split]); + return read(a) === read(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, "unchanged"); + if (!wellFormed(text, base.type)) throw new AssembleError(base.name, donor.name, half, "illFormed"); + return { ...base, name: `${base.name} (Assembled)`, text, split: top.length }; +} + +/** Does assembling `base` with `donor`'s `half` give a new, well-formed text? */ +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; + // A different card is any difference the engine reads: the text, the split, or a base re-registered since. + for (let n = 2; world.hasCard(name) && !sameCard(world.card(name).def, { ...def, name }); 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, and the half to replace. Only combinations that change the + * base's text and leave it well-formed are offered: a donor is offered if one of its + * halves gives such a text, and a base if it has such a donor. 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 donorsFor = (base: CardDef): string[] => + pool + .map((c) => c.def) + .filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h))) + .map((d) => d.name); + const bases = pool + .filter((c) => (filter.trigger === undefined || hasTrigger(c, filter.trigger)) && donorsFor(c.def).length > 0) + .map((c) => c.def.name); + if (bases.length === 0) return undefined; + const base = world.card(world.choose("base", bases)).def; + const donor = world.card(world.choose("donor", donorsFor(base))).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/12-combat/reference/ts/src/engine/aura.ts b/course/12-combat/reference/ts/src/engine/aura.ts new file mode 100644 index 0000000..0b58203 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/aura.ts @@ -0,0 +1,65 @@ +// Auras, the one routine that applies trigger multipliers, and the lookup for +// damage redirects. +// +// 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 { EntityId, 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), source: id, aura })), + ), + ...world.ruleAuras().flatMap((aura) => players.map((owner) => ({ owner, aura }))), + ]); +} + +/** + * The minion that takes damage meant for `player`'s hero, if a redirect is in force: + * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the + * snapshot. The snapshot lists minions oldest first, so that is the first one found. + */ +export function redirectFor(world: World, player: PlayerId): EntityId | undefined { + return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source; +} + +/** + * 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 (aura.type === "damageRedirect" || 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/12-combat/reference/ts/src/engine/card.ts b/course/12-combat/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..9bc8daa --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/card.ts @@ -0,0 +1,212 @@ +// 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 (Module 12: `endTurn`) + +/** 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" } + /** + * Damage that would be dealt to the aura owner's hero is dealt to the aura's minion + * instead (see `DAMAGE_STEPS` in `queue.ts`). + */ + | { type: "damageRedirect"; from: "friendlyHero"; to: "self" } + /** + * 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[]; + /** Keywords, such as `"taunt"` (Module 12: combat reads it). */ + keywords: string[]; + /** 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: [], keywords: [], 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": // Module 12: combat reads Taunt (`combat.ts`). + card.keywords.push(clause.keyword); + 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); + +/** + * Does `text` mean what it says? For a minion, every effect clause belongs to a + * trigger (none comes before the first trigger, or after an aura or keyword), and + * every trigger has at least one effect before the next trigger, aura, keyword or + * the end. The rule is for minions (Assemble makes only minions): a spell passes. + */ +export function wellFormed(text: readonly Clause[], type: CardDef["type"]): boolean { + if (type !== "minion") return true; + // The trigger whose effects are being listed: none, one with no effect yet, or one with some. + let open: "none" | "empty" | "filled" = "none"; + for (const clause of text) { + if (clause.kind === "effect") { + if (open === "none") return false; + open = "filled"; + } else { + if (open === "empty") return false; + open = clause.kind === "trigger" ? "empty" : "none"; + } + } + return open !== "empty"; +} + +/** 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/12-combat/reference/ts/src/engine/combat.ts b/course/12-combat/reference/ts/src/engine/combat.ts new file mode 100644 index 0000000..e5008ad --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/combat.ts @@ -0,0 +1,36 @@ +// Combat (Module 12): who may attack whom. +// +// An attack is one Sequence (`attack` in `queue.ts`): both characters deal their +// Attack to each other at once, as one Damage action with two targets, and the +// defender's Damage Event queues its triggers first. This file holds only the +// legality rules, so the harness can ask them without attacking. + +import type { World, EntityId } from "./world"; + +/** The keyword that makes a minion the only thing its enemies may attack. */ +export const TAUNT = "taunt"; + +/** Does `id`, a minion in play, have Taunt? */ +export function hasTaunt(world: World, id: EntityId): boolean { + return world.card(world.cardName(id)).keywords.includes(TAUNT); +} + +/** + * Why `attacker` may not attack `defender` now, or undefined if it may. The rules, in + * the order they are checked: the attacker is a minion in play; the defender is an + * enemy minion in play or the enemy hero; the attacker has some Attack; and while the + * defender's side has a minion with Taunt, only such a minion may be attacked. + */ +export function attackError(world: World, attacker: EntityId, defender: EntityId): string | undefined { + if (world.isHero(attacker) || world.zoneOf(attacker) !== "board") { + return "the attacker is not a minion in play"; + } + const enemy = world.ownerOf(attacker) === 0 ? 1 : 0; + const enemyCharacter = + world.ownerOf(defender) === enemy && (world.isHero(defender) || world.zoneOf(defender) === "board"); + if (!enemyCharacter) return "the defender is not an enemy character in play"; + if (world.attack(attacker) <= 0) return "a minion with 0 Attack cannot attack"; + const taunts = world.zone(enemy, "board").filter((id) => hasTaunt(world, id)); + if (taunts.length > 0 && !taunts.includes(defender)) return "a minion with Taunt is in the way"; + return undefined; +} diff --git a/course/12-combat/reference/ts/src/engine/death.ts b/course/12-combat/reference/ts/src/engine/death.ts new file mode 100644 index 0000000..d035e1c --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/death.ts @@ -0,0 +1,29 @@ +// 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 { EntityId, 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 neighbour is read before anything moves: removal is simultaneous. Each Death + // Event remembers where its minion stood as a handle, the minion that stays to its + // right, not as a board index: an index goes stale as soon as a minion further left + // leaves play in the same step (Module 1: hold handles, not indexes). + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, before: survivorToTheRight(world, id) })); + for (const { id, before } of deaths) world.kill(id, before); + return deaths; +} + +/** The nearest minion to the right of `id` on its board that is not leaving play in this step, if any. */ +function survivorToTheRight(world: World, id: EntityId): EntityId | undefined { + const board = world.zone(world.ownerOf(id), "board"); + return board.slice(board.indexOf(id) + 1).find((other) => !world.isDoomed(other)); +} diff --git a/course/12-combat/reference/ts/src/engine/effect.ts b/course/12-combat/reference/ts/src/engine/effect.ts new file mode 100644 index 0000000..f823560 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/effect.ts @@ -0,0 +1,76 @@ +// 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 { hits, type Action, type 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", hits: hits(targets(effect.target, ctx), effect.amount) }]; + case "draw": + return times(effect.count, () => ({ type: "draw", player })); + case "summon": { + // Several tokens stand left to right in summon order: each goes right of the one before. + const at = summonPosition(ctx); + return Array.from({ length: effect.count }, (_, i) => ({ + type: "summon" as const, + card: effect.card, + player, + at: at === undefined ? undefined : at + i, + })); + } + 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 (left of the minion its Death Event remembers); 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/12-combat/reference/ts/src/engine/event.ts b/course/12-combat/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..e48adc9 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/event.ts @@ -0,0 +1,40 @@ +// What happened, in order. The engine records an event each time it changes the game. +// The world also keeps the nesting depth each event was recorded at (`World.logDepths`). + +import type { TriggerOn } from "./card"; +import type { EntityId, PlayerId } from "./world"; + +export type Event = + /** + * `id` entered the board. `by` is the minion whose triggered ability put it there (the + * trigger resolving when it entered), or undefined for a spell, a play or a summon from + * outside a Sequence. Module 10: SpellGrid puts it on `by`'s grid tile. The course's log + * doesn't show `by`. + */ + | { type: "Summoned"; id: EntityId; by: EntityId | undefined } + | { 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 } + /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */ + | { type: "ArmorLost"; 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. + * `before` is the nearest minion to its right that did not die in the same step, if any: + * its Deathrattle summons immediately left of that minion, or at the right end. It is a + * handle because a board index goes stale when a minion further left dies in the same step. + */ + | { type: "Destroyed"; id: EntityId; before: EntityId | undefined } + | { 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 12: `player`'s turn ended. Their minions' end of turn triggers answer this. */ + | { type: "TurnEnded"; player: PlayerId } + /** + * 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/12-combat/reference/ts/src/engine/index.ts b/course/12-combat/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..f362233 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/index.ts @@ -0,0 +1,78 @@ +// 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. A second interceptor, replacement: the +// steps that rewrite a Damage before it lands (a redirect, then Armor). +// +// 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, and +// offers only results that are well-formed. A Sequence is bounded: a trigger never +// nests inside itself, and more than `DEATH_PHASE_LIMIT` Death Phases is an error. +// +// Module 12: combat (`combat.ts`: who may attack whom; Taunt) and the end of a turn. +// A Damage action deals each target its own amount, and a Queue orders its entries by +// priority before order of play, so the defender's triggers go first. +// +// 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. +// Module 11: conditions (`X ON Z IS P`), checked for each object when a rule is used. +// +// 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 AssembleFailure, 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, + wellFormed, +} from "./card"; +export type { Event } from "./event"; +export { auraUpdateOther } from "./aura"; +export { attackError, TAUNT } from "./combat"; +export { + Ctx, + DAMAGE_STEPS, + DEATH_PHASE_LIMIT, + DEFENDER, + END_OF_PHASE, + hits, + NORMAL, + runSequence, + summon, + type Action, + type DamageStep, + type EndOfPhaseStep, + type Hit, + type Priority, +} 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/12-combat/reference/ts/src/engine/puzzle/condition.ts b/course/12-combat/reference/ts/src/engine/puzzle/condition.ts new file mode 100644 index 0000000..7489770 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/condition.ts @@ -0,0 +1,21 @@ +// Conditions (Module 11): `X ON Z IS P` gives an X the property P only while it is on a Z. +// +// The parser reads a condition into the rule (`Rule.conditions`); the engine never +// decides it there. A condition is evaluated for one object, against the board as +// it is, every time a rule is about to be used: by `Level.hasProperty` (and so by +// movement, the hazards and the win check), and by conversion. What a condition +// word means is a `ConditionCheck` that content provides, registered by word, as +// movement responses and hazards are. The engine knows only that a word with role +// `"condition"` may stand between the subjects and `IS`. + +import type { ObjectId } from "./grid"; +import type { Level } from "./level"; + +/** + * Content's meaning for one condition word: does the condition, with these nouns + * (in the order they are written), hold for object `id` on the board as it is now? + */ +export type ConditionCheck = (level: Level, id: ObjectId, nouns: readonly string[]) => boolean; + +/** Content's condition checks, by condition word: `{ ON: on }`. */ +export type ConditionChecks = Readonly>; diff --git a/course/12-combat/reference/ts/src/engine/puzzle/grid.ts b/course/12-combat/reference/ts/src/engine/puzzle/grid.ts new file mode 100644 index 0000000..91feb4d --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/grid.ts @@ -0,0 +1,145 @@ +// 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; +} + +/** What `Board.restore` puts back (Module 9, undo): every object, oldest first, and the id counter. */ +export interface BoardState { + objects: Obj[]; + nextId: ObjectId; +} + +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()]; + } + + /** A copy of everything the board holds (Module 9, undo). */ + snapshot(): BoardState { + return { objects: this.objects().map((o) => ({ ...o })), nextId: this.nextId }; + } + + /** Put the board back as `state` recorded it: the same objects, ids, ages and id counter. */ + restore(state: BoardState): void { + this.objectsById.clear(); + for (const o of state.objects) this.objectsById.set(o.id, { ...o }); + this.nextId = state.nextId; + } +} diff --git a/course/12-combat/reference/ts/src/engine/puzzle/index.ts b/course/12-combat/reference/ts/src/engine/puzzle/index.ts new file mode 100644 index 0000000..11a1286 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/index.ts @@ -0,0 +1,47 @@ +// 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. +// +// Module 11: conditions. A word with role `"condition"` may stand between a rule's +// subjects and `IS` (`X ON Z IS P`); the rule carries it (`Rule.conditions`). +// What it means is a `ConditionCheck` content registers by word. `Level` asks the +// checks every time it uses a rule, against the board as it is then. + +export type { ConditionCheck, ConditionChecks } from "./condition"; +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, + sameConditions, + type Condition, + 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/12-combat/reference/ts/src/engine/puzzle/level.ts b/course/12-combat/reference/ts/src/engine/puzzle/level.ts new file mode 100644 index 0000000..8bf1b1f --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/level.ts @@ -0,0 +1,316 @@ +// 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. +// +// Undo (Module 9): before each move or turn the level saves the board (every +// object's id, kind and position, oldest first, and the id counter) and `won`. +// The rules are not saved: undo restores the board and reparses. +// +// 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. SpellGrid doesn't offer undo: Part I has +// state the level's snapshot doesn't cover. +// +// Module 11: a rule may carry conditions (`X ON Z IS P`). They are not +// decided when the rules are compiled. Every question about an object (`hasProperty`, +// conversion) asks content's `ConditionCheck`s against the board as it is at that +// moment, and uses only the rules whose conditions hold for that object. + +import type { ConditionChecks } from "./condition"; +import { Board, Kind, type BoardState, 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; + /** What `undo` puts back: one entry per move or turn, the latest last (Module 9). */ + private history: { board: BoardState; won: boolean }[] = []; + + /** + * A level from its definition, the words content provides, content's movement + * responses and hazards, each in the order they are to run, and content's + * condition checks, by word. Throws if a word with role `"condition"` has no check. + */ + constructor( + def: LevelDef, + words: readonly WordDef[], + private readonly moveResponses: readonly Response[], + private readonly hazards: readonly Hazard[], + private readonly conditions: ConditionChecks, + ) { + for (const w of words) { + if (w.role === "condition" && !Object.hasOwn(conditions, w.name.toUpperCase())) { + throw new Error(`the condition word ${w.name} has no condition check`); + } + } + 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.history = []; + 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 that applies to it + * (`appliesTo`) gives it the property and no active negated rule that applies to + * it takes it away. A destroyed object has no properties. + */ + hasProperty(id: ObjectId, property: string): boolean { + if (this.board.find(id) === undefined) return false; + const aboutIt = this.active.filter((r) => r.complement === property && this.appliesTo(r, id)); + return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated); + } + + /** Does the noun `word`, as the subject of a rule, name object `id`? */ + names(word: string, id: ObjectId): boolean { + return this.lexicon.names(word, this.board.get(id).kind); + } + + /** + * Does `rule` apply to object `id` now? Its subject names the object, and each of + * its conditions holds for the object on the board as it is (Module 11). + */ + private appliesTo(rule: Rule, id: ObjectId): boolean { + return ( + this.names(rule.subject, id) && rule.conditions.every((c) => this.conditions[c.word](this, id, c.nouns)) + ); + } + + /** + * 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.save(); + 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 { + this.save(); + 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; + } + } + } + + /** + * Put the level back as it was before the last `moveYou` or `step` (Module 9), + * and reparse. False, and no change, if there is none; `place` forgets them all. + */ + undo(): boolean { + const last = this.history.pop(); + if (last === undefined) return false; + this.board.restore(last.board); + this.hasWon = last.won; + this.reparse(); + return true; + } + + private save(): void { + this.history.push({ board: this.board.snapshot(), won: this.hasWon }); + } + + /** + * 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. + * + * An object steps at most once in a movement phase. A `YOU` object that an + * earlier move already carried along (pushed, or moved by any other response) + * doesn't start a move of its own, and no later move can make it step again. + */ + 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); + const stepped = new Set(); + for (const id of you) { + if (this.board.find(id) === undefined) continue; // destroyed by an earlier move + if (stepped.has(id)) continue; // already moved, by an earlier move + const planner = new Planner(this, dir, stepped); + if (!planner.tryMove(id)) continue; + const plan = planner.result(); + this.commit(plan, dir, destroy); + for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved); + } + } + + /** 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`. + * + * Negation is settled per object, as in `hasProperty`: an object doesn't become + * a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + * conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an 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. Only the rules that + * apply to the object are used: their conditions are checked on that board too. + */ + 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.appliesTo(r, o.id)); + 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; + if (aboutIt.some((n) => n.negated && n.complement === r.complement)) 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/12-combat/reference/ts/src/engine/puzzle/movement.ts b/course/12-combat/reference/ts/src/engine/puzzle/movement.ts new file mode 100644 index 0000000..c3fd8d4 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/movement.ts @@ -0,0 +1,130 @@ +// 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. `stepped` holds the objects that have + * already stepped in this movement phase, in an earlier committed plan: an object + * steps at most once per phase, so none of them steps again. + */ +export class Planner { + private plan: Plan = { moving: new Set(), destroyed: new Set() }; + + constructor( + private readonly level: MoveContext, + private readonly dir: Dir, + private readonly stepped: ReadonlySet, + ) {} + + /** 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. An + * object that has already stepped in this movement phase doesn't step again, and + * doesn't hold the mover back either: the answer is yes, and nothing is added, so + * objects that began the phase stacked on one tile end it stacked. + */ + tryMove(id: ObjectId): boolean { + if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; + if (this.stepped.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/12-combat/reference/ts/src/engine/puzzle/parse.ts b/course/12-combat/reference/ts/src/engine/puzzle/parse.ts new file mode 100644 index 0000000..7234604 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/parse.ts @@ -0,0 +1,227 @@ +// 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. +// +// Module 11: conditions. `NOUN [AND NOUN]* [CONDITION NOUN [AND NOUN]*] IS ...`: a word +// whose role is `"condition"` may stand between the subjects and `IS`, followed by +// the nouns it is about. The condition belongs to every subject of the sentence, +// and AND among its nouns means all of them. A run of nouns before `IS` is the +// condition's when a condition word stands before it, and then it is not a run of +// subjects. Stacked text still yields every possible rule: a stack of nouns after +// the condition word gives one rule per noun, as a stack after `IS` does. +// +// The parser only reads; which rules win is the resolution pass (`resolve`), and +// whether a condition holds is decided for each object when someone asks (`Level`). + +import type { Board, ObjectId } from "./grid"; +import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word"; + +/** + * A condition on a rule (Module 11): the condition word and the nouns after it, in + * the order they are written. `X ON Y AND Z IS P` has `{ word: "ON", + * nouns: ["Y", "Z"] }`. + */ +export interface Condition { + word: string; + nouns: string[]; +} + +/** + * One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`, + * applying to an object only while each of its `conditions` holds for that object + * (none for an unconditional rule). `text` is the text objects that spell it + * (subject, condition word and nouns if any, `IS`, `NOT` if any, complement), or + * empty for a base rule. + */ +export interface Rule { + subject: string; + complement: string; + complementRole: Role; + negated: boolean; + conditions: Condition[]; + text: ObjectId[]; +} + +/** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`, `"X ON Y AND Z IS P"`. */ +export const ruleText = (r: Rule): string => { + const conditions = r.conditions.map((c) => ` ${c.word} ${c.nouns.join(` ${AND} `)}`).join(""); + return `${r.subject}${conditions} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`; +}; + +/** The same conditions: the same words, each with the same nouns in any order. */ +export const sameConditions = (a: Rule, b: Rule): boolean => { + const key = (r: Rule) => JSON.stringify(r.conditions.map((c) => [c.word, [...c.nouns].sort()])); + return key(a) === key(b); +}; + +/** `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, complement and conditions, opposite sign: `X IS NOT Y` negates + * `X IS Y`, and `X ON Z IS NOT Y` negates `X ON Z IS Y` (Module 11), but not `X IS Y`. + */ +export const negates = (n: Rule, r: Rule): boolean => + n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement && sameConditions(n, r); + +/** 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, + conditions: [], + 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 cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]); + for (const { subjects, conditions, text } of beforeIs(tiles, [o.x - dx, o.y - dy], [-dx, -dy])) { + for (const s of subjects) { + for (const c of cs) { + rules.push({ + subject: s.word, + complement: c.token.word, + complementRole: c.token.role, + negated: c.not !== undefined, + conditions, + text: [s.id, ...text, o.id, ...(c.not ? [c.not.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]; + +/** What stands before an `IS` (Module 11): the subjects, with the condition on them if there is one. */ +interface Reading { + subjects: Token[]; + conditions: Condition[]; + /** The condition word and its nouns, in reading order (empty without a condition). */ + text: ObjectId[]; +} + +const isCondition = (t: Token) => t.role === "condition"; + +/** + * Every way to read the words before an `IS`, walking backwards from `at`. + * + * - The run of subjects, without a condition, unless a condition word stands + * before the run: then those nouns are the condition's, not subjects. + * - For every run of nouns that has a condition word before it, the run of + * subjects before that word, with the condition. A stack among the condition's + * nouns gives one reading per noun (`nounRuns`). + */ +function beforeIs(tiles: Tiles, at: Pos, step: Pos): Reading[] { + const readings: Reading[] = []; + const run = subjects(tiles, at, step); + if (!wordsAt(tiles, run.stop).some(isCondition)) readings.push({ subjects: run.tokens, conditions: [], text: [] }); + for (const nouns of nounRuns(tiles, at, step)) { + const inOrder = [...nouns.tokens].reverse(); + for (const word of wordsAt(tiles, nouns.stop).filter(isCondition)) { + readings.push({ + subjects: subjects(tiles, ahead(nouns.stop, step), step).tokens, + conditions: [{ word: word.word, nouns: inOrder.map((t) => t.word) }], + text: [word.id, ...inOrder.map((t) => t.id)], + }); + } + } + return readings; +} + +/** + * 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). + * `stop` is the tile the walk stopped on, before the run's first word. + */ +function subjects(tiles: Tiles, at: Pos, step: Pos): { tokens: Token[]; stop: Pos } { + const before = ahead(at, step); + if (holds(tiles, before, NOT)) return { tokens: [], stop: before }; + const found = wordsAt(tiles, at).filter((t) => t.role === "noun"); + if (found.length > 0 && holds(tiles, before, AND)) { + const rest = subjects(tiles, ahead(before, step), step); + if (rest.tokens.length > 0) return { tokens: [...found, ...rest.tokens], stop: rest.stop }; + } + return { tokens: found, stop: before }; +} + +/** + * Every run of nouns walking backwards from `at`, one noun from each tile, joined + * by AND; each with the tile before its first noun (`stop`). Runs that end early, + * at an AND, are included: a condition word stacked on that AND starts a reading. + * A `NOT` before a noun ends every run through it. + */ +function nounRuns(tiles: Tiles, at: Pos, step: Pos): { tokens: Token[]; stop: Pos }[] { + const before = ahead(at, step); + if (holds(tiles, before, NOT)) return []; + const runs: { tokens: Token[]; stop: Pos }[] = []; + for (const noun of wordsAt(tiles, at).filter((t) => t.role === "noun")) { + runs.push({ tokens: [noun], stop: before }); + if (!holds(tiles, before, AND)) continue; + for (const rest of nounRuns(tiles, ahead(before, step), step)) { + runs.push({ tokens: [noun, ...rest.tokens], stop: rest.stop }); + } + } + return runs; +} + +/** + * 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/12-combat/reference/ts/src/engine/puzzle/resolve.ts b/course/12-combat/reference/ts/src/engine/puzzle/resolve.ts new file mode 100644 index 0000000..969b8ef --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/resolve.ts @@ -0,0 +1,29 @@ +// 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`. +// +// Module 11: both steps pair rules only when their conditions are the same. +// `X ON Z IS NOT P` cancels `X ON Z IS P` here, but not `X IS P`: +// which of those wins depends on where each X stands, so it is settled per +// object when someone asks (`Level.hasProperty`, and conversion), among the rules +// whose conditions hold for that object. + +import { isConversion, isIdentity, negates, sameConditions, 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 = (r: Rule) => + survivesNegation.some((l) => !l.negated && isIdentity(l) && l.subject === r.subject && sameConditions(l, r)); + return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r)); +} diff --git a/course/12-combat/reference/ts/src/engine/puzzle/turn.ts b/course/12-combat/reference/ts/src/engine/puzzle/turn.ts new file mode 100644 index 0000000..4742618 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/src/engine/puzzle/word.ts b/course/12-combat/reference/ts/src/engine/puzzle/word.ts new file mode 100644 index 0000000..aa02514 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/puzzle/word.ts @@ -0,0 +1,110 @@ +// 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. +// +// Module 11: a fourth role, `"condition"`. The engine knows only where a condition +// word may stand in a sentence (between the subjects and `IS`); what it means is a +// check content provides (`ConditionCheck`). + +import type { Board, Kind, ObjectId } from "./grid"; + +export type Role = "noun" | "property" | "operator" | "condition"; + +/** + * 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/12-combat/reference/ts/src/engine/queue.ts b/course/12-combat/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..cd4b267 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/queue.ts @@ -0,0 +1,396 @@ +// 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 4, second interceptor: replacement. Before a Damage changes the world, +// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes +// who takes it, then Armor takes what it can of damage to a hero. +// +// 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 5, robustness: once a test can write any card as data, one card can make a +// Sequence endless. A trigger is never queued while that same trigger of that same +// minion is resolving (the nesting guard in `react`), and a Sequence that needs more +// than `DEATH_PHASE_LIMIT` Death Phases is an error. +// +// 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. +// +// Module 12: combat and the end of a turn. An attack is one Damage action whose two +// targets take different amounts (`Hit`), and the triggers answering the defender's +// Damage Event queue before the attacker's, whatever their order of play. That +// exception is data on the queue entry, a priority: `react` orders its Queue by +// priority first and keeps the order it was filled in (order of play) within one +// priority. Ending a turn raises an event that the turn's player's end of turn +// triggers answer. + +import { assembleFromPool } from "./assemble"; +import { attackError } from "./combat"; +import { activations, auraUpdateOther, redirectFor } 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"; + +/** + * How early the triggers answering an event queue (Module 12). A Queue resolves its + * entries highest priority first and, within one priority, in the order it was filled: + * for each event in order of play, each listener in order of play. + */ +export type Priority = number; + +/** The priority of every entry, except where a rule says otherwise. */ +export const NORMAL: Priority = 0; + +/** Combat: the triggers answering the defender's Damage Event go first. */ +export const DEFENDER: Priority = 1; + +/** One target of a Damage action, the amount it takes, and the priority of the triggers its Damage Event queues. */ +export interface Hit { + target: EntityId; + amount: number; + priority: Priority; +} + +/** Something the engine can do. Card effects return these instead of changing the world. */ +export type Action = + /** Deal each hit's amount to its target at once: all damage first, then the reactions. */ + | { type: "damage"; hits: Hit[] } + | { 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 } + /** Combat (Module 12): `attacker` and `defender` deal their Attack to each other at once. */ + | { type: "attack"; attacker: EntityId; defender: EntityId } + /** The end of `player`'s turn (Module 12): their minions' end of turn triggers answer it. */ + | { type: "endTurn"; player: PlayerId }; + +/** The same amount to each target, at normal priority: what area damage and a spell deal. */ +export function hits(targets: readonly EntityId[], amount: number): Hit[] { + return targets.map((target) => ({ target, amount, priority: NORMAL })); +} + +/** + * 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"]; + +/** + * A step that may rewrite one Damage to one target before the world changes: + * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force + * goes to that aura's minion instead (the oldest one's, if several); + * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health. + */ +export type DamageStep = "redirect" | "armor"; + +/** + * The order the steps run in. Each runs once per Damage, so a rewritten Damage is + * never offered to a redirect again. A redirect comes first: Armor protects only + * the hero that actually takes the damage. + */ +export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"]; + +/** 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 where it stood: immediately left of the minion its Death Event remembers. + */ + 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); + } +} + +/** + * The most Death Phases one Sequence may have. One more is an error, not a cut-off: + * a Sequence that stopped early would resolve differently without saying so. + */ +export const DEATH_PHASE_LIMIT = 64; + +/** 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++) { + // Between Death Phases no trigger is resolving, so the error leaves no block open. + if (round > DEATH_PHASE_LIMIT) { + throw new Error(`Death Phase limit: more than ${DEATH_PHASE_LIMIT} Death Phases in one Sequence`); + } + 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 sorted = [...action.hits].sort((a, b) => world.orderOfPlay(a.target)! - world.orderOfPlay(b.target)!); + const events = sorted.map((hit) => applyDamage(world, hit.target, hit.amount)); + react(world, events, sorted.map((hit) => hit.priority)); + break; + } + case "attack": { + const { attacker, defender } = action; + const error = attackError(world, attacker, defender); + if (error !== undefined) throw new Error(error); + // Both amounts are read before either is dealt: the damage is simultaneous. A + // hero has no Attack, so it deals nothing back; nor does a 0 Attack minion. + const attack: Hit[] = [{ target: defender, amount: world.attack(attacker), priority: DEFENDER }]; + const counter = world.attack(defender); + if (counter > 0) attack.push({ target: attacker, amount: counter, priority: NORMAL }); + perform(world, { type: "damage", hits: attack }); + break; + } + case "endTurn": { + const ended: Event = { type: "TurnEnded", player: action.player }; + world.record(ended); + react(world, [ended]); + 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); + } +} + +/** + * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it. + * Returns its Damage Event, which names whoever took it. + */ +function applyDamage(world: World, target: EntityId, amount: number): Event { + let taker = target; + let absorbed = 0; + for (const step of DAMAGE_STEPS) { + switch (step) { + case "redirect": + if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker; + break; + case "armor": + if (world.isHero(taker)) { + const player = world.ownerOf(taker); + absorbed = Math.min(world.armor(player), amount); + if (absorbed > 0) world.loseArmor(player, absorbed); + } + break; + } + } + world.damage(taker, amount, absorbed); + return { type: "Damaged", target: taker, amount }; +} + +/** + * A Phase: queue every trigger that responds to `events`, then resolve them one by one. + * `priorities[i]` is the priority of the entries answering `events[i]` (`NORMAL` if absent). + */ +function react(world: World, events: Event[], priorities: readonly Priority[] = []): void { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + // The nesting guard: a trigger that is resolving right now is not queued again, so + // a trigger is never nested inside itself. + const queue: [EntityId, number, Trigger, Event, Priority][] = []; + events.forEach((event, e) => { + for (const listener of listeners(world, event)) { + world.card(world.cardName(listener)).triggers.forEach((trigger, index) => { + if (responds(world, trigger.on, listener, event) && !world.isResolving(listener, index)) { + queue.push([listener, index, trigger, event, priorities[e] ?? NORMAL]); + } + }); + } + }); + // Priority first; the sort is stable, so entries of one priority keep the order above. + queue.sort((a, b) => b[4] - a[4]); + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, index, 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 + ? positionLeftOf(world, world.ownerOf(source), event.before) + : world.boardIndex(source); + // The trigger's consequences are one level deeper in the log tree. + world.openBlock(source, index); + runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); + world.closeBlock(); + } + } +} + +/** + * The board index, right now, immediately left of `before` on `owner`'s board; the + * right end if `before` is undefined or no longer there. A Death Event remembers the + * minion, not an index, so this still holds after other minions have left or arrived. + */ +function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number { + const board = world.zone(owner, "board"); + const index = before === undefined ? -1 : board.indexOf(before); + return index < 0 ? board.length : index; +} + +/** + * 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 === "TurnEnded") return on === "endOfTurn" && world.ownerOf(listener) === event.player; + 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/12-combat/reference/ts/src/engine/spellgrid.ts b/course/12-combat/reference/ts/src/engine/spellgrid.ts new file mode 100644 index 0000000..c27280e --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/spellgrid.ts @@ -0,0 +1,282 @@ +// 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 four 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. +// 4. Summoned minions enter the grid. In the same place, before the dead leave: a +// minion that a minion's ability summoned (`Summoned` events with a `by`), whose card +// has a noun, becomes an object of that noun on its summoner's tile. One summoned by +// a spell, or with no noun, is in play but not on the grid. +// +// 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`. + * + * Module 11: a rule with a condition gives no aura. A condition holds or not for one + * object on the grid, and a trigger word names no object (the course's choice). + */ +export function ruleAuras(rules: readonly puzzle.Rule[], words: SeamWords): Aura[] { + const auras: Aura[] = []; + for (const rule of rules) { + if (rule.conditions.length > 0) continue; + 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[], + conditions: puzzle.ConditionChecks, + private readonly seamWords: SeamWords, + ) { + this.world = new World(library); + this.level = new puzzle.Level(def, words, responses, hazards, conditions); + 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(); + } + + /** + * Why `cast(card, target)` can't be done now, or `undefined` if it can: the card must + * be a spell in player 0's hand, and a spell that hits a chosen minion needs a target + * minion on the grid. Nothing changes when the answer is no. + */ + checkCast(card: string, target?: puzzle.ObjectId): string | undefined { + const problem = this.checkHand(card, "spell"); + if (problem !== undefined) return problem; + const chooses = this.world.card(card).onCast.some((e) => "target" in e && e.target === "chosen"); + if (target === undefined) return chooses ? `"${card}" needs a target minion` : undefined; + const object = this.level.board.find(target); + if (object === undefined || !this.entities.has(target)) return `${target} is not a minion on the grid`; + return undefined; + } + + /** Why `play(card, x, y)` can't be done now, or `undefined` if it can. */ + checkPlay(card: string, x: number, y: number): string | undefined { + const problem = this.checkHand(card, "minion"); + if (problem !== undefined) return problem; + if (this.world.card(card).def.noun === undefined) return `"${card}" has no noun: it can't stand on the grid`; + if (!this.level.board.inBounds(x, y)) return `(${x}, ${y}) is off the grid`; + if (this.level.board.at(x, y).length > 0) return `(${x}, ${y}) is not empty`; + return undefined; + } + + /** Cast a spell from player 0's hand, as one Sequence. `target` is a minion's id. Throws if `checkCast` says no. */ + cast(card: string, target?: puzzle.ObjectId): void { + const problem = this.checkCast(card, target); + if (problem !== undefined) throw new Error(problem); + this.world.moveTo(this.inHand(card)!, "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. Throws if `checkPlay` says no. + */ + play(card: string, x: number, y: number): puzzle.ObjectId { + const problem = this.checkPlay(card, x, y); + if (problem !== undefined) throw new Error(problem); + const inHand = this.inHand(card)!; + const id = this.level.board.place(this.nounOf(card), x, y); + this.link(inHand, id); + this.sequence({ type: "play", id: inHand }); + return id; + } + + private inHand(card: string): EntityId | undefined { + return this.world.zone(0, "hand").find((id) => this.world.cardName(id) === card); + } + + private checkHand(card: string, type: "minion" | "spell"): string | undefined { + if (this.inHand(card) === undefined) return `"${card}" is not in the hand`; + if (this.world.card(card).def.type !== type) return `"${card}" is not a ${type}`; + return undefined; + } + + // ---- 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 seams 3 and 4: 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 yet: 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); + } + // Seam 4: summoned minions, in summon order, on their summoner's tile. The dead are still + // on the grid here, so a Deathrattle's summoner is found where it died; a minion summoned + // earlier in the list is already placed, so it can be a summoner too. + for (const event of events) { + if (event.type !== "Summoned" || event.by === undefined) continue; + const card = this.world.cardName(event.id); + if (this.ids.has(event.id) || this.world.card(card).def.noun === undefined) continue; + const by = this.ids.get(event.by); + const summoner = by === undefined ? undefined : board.find(by); + if (summoner !== undefined) this.link(event.id, board.place(this.nounOf(card), summoner.x, summoner.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/12-combat/reference/ts/src/engine/world.ts b/course/12-combat/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..e46a707 --- /dev/null +++ b/course/12-combat/reference/ts/src/engine/world.ts @@ -0,0 +1,382 @@ +// 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 minion has it, and that minion's owner. An aura from outside + * the cards (Module 10) has no minion, and is in force once for each player. + */ +export interface ActiveAura { + owner: PlayerId; + source?: EntityId; + 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 nesting depth at which each event in `events` was recorded, index for index. */ + private depths: number[] = []; + /** + * The triggers resolving right now, one inside another, outermost first: each is a + * minion and the index of the trigger in its card's `triggers`. How many there are + * is the nesting depth of the log tree. + */ + private resolving: [EntityId, number][] = []; + /** 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; + // The summoner is the minion whose trigger is resolving (the innermost one), if any. + this.record({ type: "Summoned", id, by: this.resolving.at(-1)?.[0] }); + } + } + + 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; + } + + /** The nesting depth of each event in `log()`, index for index: 0 for the outermost action's own events. */ + logDepths(): readonly number[] { + return this.depths; + } + + // ---- Changes: each one records its event ---- + + record(event: Event): void { + this.events.push(event); + this.depths.push(this.resolving.length); + } + + /** + * Trigger number `trigger` of `source` starts resolving: the events it causes are + * recorded one level deeper. + */ + openBlock(source: EntityId, trigger: number): void { + this.resolving.push([source, trigger]); + } + + /** The trigger that last opened a block has finished resolving. */ + closeBlock(): void { + this.resolving.pop(); + } + + /** Is trigger number `trigger` of `source` resolving right now (at any depth)? */ + isResolving(source: EntityId, trigger: number): boolean { + return this.resolving.some(([s, t]) => s === source && t === trigger); + } + + /** 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 = []; + this.depths = []; + } + + /** + * `target` takes `amount` damage, of which `absorbed` was already taken by Armor + * (see `loseArmor`): Health drops by the rest. The event shows the full amount. + */ + damage(target: EntityId, amount: number, absorbed = 0): void { + this.entity(target).health -= amount - absorbed; + this.record({ type: "Damaged", target, amount }); + } + + /** `player`'s hero's Armor absorbs `amount` damage. */ + loseArmor(player: PlayerId, amount: number): void { + this.sides[player].armor -= amount; + this.record({ type: "ArmorLost", player, 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, before: EntityId | undefined): void { + this.entity(id).pendingDestroy = false; + this.moveTo(id, "graveyard"); + this.record({ type: "Destroyed", id, before }); + } + + 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/12-combat/reference/ts/src/harness.ts b/course/12-combat/reference/ts/src/harness.ts new file mode 100644 index 0000000..1fb15a2 --- /dev/null +++ b/course/12-combat/reference/ts/src/harness.ts @@ -0,0 +1,313 @@ +// 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, conditions, hazards, movement, seams, words } from "./content"; +import { + assemble, + attackError, + CardLibrary, + END_OF_PHASE, + hits, + 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: "ArmorLost"; 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 }; + if (event.type === "Summoned") return { type: "Summoned", id: event.id }; + // A played minion's `Summoned` already shows it entering the board. + if (event.type === "Played") return undefined; + // The end of a turn shows in the triggers that answer it. + if (event.type === "TurnEnded") 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 }; +} + +/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */ +function eventText(event: GameEvent): string { + const { type, ...fields } = event; + const shown = Object.entries(fields).map( + ([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`, + ); + return `${type}{${shown.join(", ")}}`; +} + +/** The log as a tree: one event per line, two spaces of indent per level of nesting. */ +function logTree(world: World, view: (event: Event) => GameEvent | undefined): string { + const depths = world.logDepths(); + return world + .log() + .map((event, i) => { + const shown = view(event); + return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`; + }) + .join(""); +} + +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; + logTree(): string; // the log as an indented tree + // 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" + // Module 12 + orderAttack(attacker: Id, defender: Id): void; // one Sequence; throws Error(canAttack's reason) if illegal + canAttack(attacker: Id, defender: Id): string | null; // why not, or null if the attack is legal + endTurn(player: Player): void; // one Sequence: the player's end of turn triggers +} + +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", hits: hits([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(), + logTree: () => logTree(world, toGameEvent), + + 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), + + orderAttack: (attacker, defender) => runSequence(world, { type: "attack", attacker, defender }), + canAttack: (attacker, defender) => attackError(world, attacker, defender) ?? null, + endTurn: (player) => runSequence(world, { type: "endTurn", player }), + }; +} + +// ======== 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", "BABA ON KEY IS WIN"), 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 + stackAt(x: number, y: number): string[]; // the same tile, oldest object first + // 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 + undo(): boolean; // back to before the last step or move; false if there is none +} + +/** 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(), conditions()); + 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(), + stackAt: (x, y) => + lvl.board + .at(x, y) + .map((o) => puzzle.Kind.format(o.kind)), + step: (dir) => lvl.step(dir), + won: () => lvl.won(), + lost: () => lvl.lost(), + undo: () => lvl.undo(), + }; +} + +// ======== 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; + logTree(): string; // Part I's log as an indented tree, with grid ids + setChooser(choose: Chooser): void; // makes Assemble's choices + withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step + // For the command line: why `cast`/`play` would fail, or null if they wouldn't + checkCast(card: string, target?: Id): string | null; + checkPlay(card: string, x: number, y: number): string | null; +} + +/** 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(), conditions(), 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(), + stackAt: (x, y) => + lvl.board + .at(x, y) + .map((o) => puzzle.Kind.format(o.kind)), + step: (dir) => g.step(dir), + won: () => lvl.won(), + lost: () => lvl.lost(), + // Part I has state (health, hands, the log) that the level's snapshot doesn't cover. + undo: () => { + throw new Error("undo is not supported in SpellGrid"); + }, + + 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(), + logTree: () => + logTree(g.world, (event) => { + const shown = toGameEvent(event); + return shown === undefined ? undefined : withIds(shown); + }), + 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()), + checkCast: (card, target) => g.checkCast(card, target) ?? null, + checkPlay: (card, x, y) => g.checkPlay(card, x, y) ?? null, + }; +} diff --git a/course/12-combat/reference/ts/tests/cli.test.ts b/course/12-combat/reference/ts/tests/cli.test.ts new file mode 100644 index 0000000..cb3bbcb --- /dev/null +++ b/course/12-combat/reference/ts/tests/cli.test.ts @@ -0,0 +1,70 @@ +// 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_prints_the_log_tree_of_each_command", () => { + const { out } = play("l1-double-tap", ["w", "w", "c Whirlwind", "q"]); + // The moves run no Sequence, so only the cast prints a log: its tree, Deathrattles nested. + expect(out.split("Log:\n")).toHaveLength(2); + expect(out).toContain( + [ + "Log:", + 'SpellCast{card: "Whirlwind"}', + "Damaged{target: 32, amount: 1}", + "Damaged{target: 33, amount: 1}", + "Destroyed{id: 32}", + "DeathPhase{round: 1}", + 'Triggered{source: 32, trigger: "deathrattle"}', + " Damaged{target: 33, amount: 1}", + 'Triggered{source: 32, trigger: "deathrattle"}', + " Damaged{target: 33, amount: 1}", + "Destroyed{id: 33}", + "DeathPhase{round: 2}", + "", + ].join("\n"), + ); +}); + +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:"); +}); + +// The two reference CLIs print the same text for the same input (tools/diff-tracks.mjs checks it). +test("cli_prints_card_names_as_typed", () => { + const { out } = play("l3-simultaneous-paradox", ['c a"b', "q"]); + expect(out).toContain('"a"b" is not in the hand'); +}); + +test("cli_numbers_are_plain_digits", () => { + // "-1" is not a coordinate, so it is part of the card's name. + const cast = play("l1-double-tap", ["c Whirlwind -1 0", "q"]); + expect(cast.out).toContain('"Whirlwind -1 0" is not in the hand'); + // "+2" is not a number, so the chooser takes the first option. + const graft = play("l2-spliced-corpse", ["c Graft", "+2", "1", "1", "q"]); + expect(graft.out).toContain("Hand: Clay Golem (Assembled)"); +}); diff --git a/course/12-combat/reference/ts/tsconfig.json b/course/12-combat/reference/ts/tsconfig.json new file mode 100644 index 0000000..604c7c5 --- /dev/null +++ b/course/12-combat/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/12-combat/reference/ts/vitest.config.ts b/course/12-combat/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/12-combat/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/12-combat/scenarios.md b/course/12-combat/scenarios.md new file mode 100644 index 0000000..c06cc4f --- /dev/null +++ b/course/12-combat/scenarios.md @@ -0,0 +1,132 @@ +# Module 12 scenarios: Combat + +Each heading is a test name. The Rust test lives in `tests/rust/test_12.rs` and the TypeScript test in `tests/ts/test_12.test.ts`; both state exactly what is written here. Player 0 is the first player, player 1 the second. Minions are summoned in the order listed, so the first one listed has the lowest order of play; each test says whose each minion is. Each test calls `clearLog()` after its setup, so `log()` holds only the events of the actions under test. Every test starts with `newGame` and a deck of 5 `Test Filler` for each player. + +This module adds three harness functions: + +- `orderAttack(attacker, defender)` runs one Sequence: `attacker` attacks `defender`. If the attack is illegal, nothing happens: TypeScript throws an `Error` whose message is `canAttack`'s reason, and Rust returns `Err(reason)`. +- `canAttack(attacker, defender)` returns why the attack is illegal (a string), or `null` (Rust: `None`) if it is legal. +- `endTurn(player)` runs one Sequence: the end of `player`'s turn. + +The rules of an attack: + +1. **Legality.** The attacker is a minion in play. The defender is an enemy minion in play or the enemy hero. The attacker's Attack is more than 0. While the defender's side has a minion with Taunt, only a minion with Taunt may be attacked: the reason then contains `Taunt`. A reason for 0 Attack contains `Attack`. +2. **Damage.** The attacker deals its Attack to the defender and, at the same time, the defender deals its Attack to the attacker: one Damage action with two targets, each with its own amount, so both `Damaged` events come before any trigger. As for all area damage, the `Damaged` events are in order of play. A hero has no Attack and deals nothing back, nor does a minion with 0 Attack. +3. **Priority.** The triggers answering the defender's `Damaged` event go before those answering the attacker's, whatever their order of play. Within each, the usual order holds. +4. **Then** the usual end of the outermost Phase: the dead leave play in one Death Creation Step, and their Deathrattles resolve in a Death Phase. + +The end of a turn: every trigger of `player`'s minions that listens for `endOfTurn` responds, in order of play, and resolves as any trigger does. Its `Triggered` event says `trigger: "endOfTurn"`. A trigger multiplier for `endOfTurn` (Drakkari Enchanter) is an aura and follows the Aura Update schedule of Module 4. The course has no mana, no limit of one attack per turn and no summoning sickness. + +Cards used (text from `course/catalog.json`): + +- `Loot Hoarder`, 2/1: "Deathrattle: Draw a card." +- `Acolyte of Pain`, 1/4: "Whenever this minion takes damage, draw a card." +- `Harvest Golem`, 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." +- `Unstable Ghoul`, 1/3: "Taunt. Deathrattle: Deal 1 damage to all minions." +- `Armorsmith`, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." +- `Bolf Ramshield`, 3/9: "Whenever your hero takes damage, this minion takes it instead." +- `Drakkari Enchanter`, 1/5: "Your end of turn effects trigger twice." +- `Test Filler` (fictional), 1/1, no text. +- `Test Bell` (fictional), 0/3: "At the end of your turn, deal 1 damage to the enemy hero." +- `Test Conjurer` (fictional, written as data in the test), 1/1: "At the end of your turn, summon a Drakkari Enchanter." + +### test_12_combat_is_simultaneous + +- Setup: Summon `Loot Hoarder` for player 0 (id A), then `Loot Hoarder` for player 1 (id D). +- Action: `orderAttack(A, D)`. +- Assertions: + - Call the card in `hand(0)` C0 and the card in `hand(1)` C1. + - The log is exactly, in this order: `Damaged{target: A, amount: 2}`, `Damaged{target: D, amount: 2}`, `Destroyed{id: A}`, `Destroyed{id: D}`, `DeathPhase{round: 1}`, `Triggered{source: A, trigger: "deathrattle"}`, `Drew{player: 0, id: C0}`, `Triggered{source: D, trigger: "deathrattle"}`, `Drew{player: 1, id: C1}`. + - `zoneOf(A)` and `zoneOf(D)` are graveyard. + - Both Hoarders deal their damage, though each one's damage kills the other: the damage is simultaneous. Both die in the same Death Creation Step, and their Deathrattles resolve in order of play in one Death Phase. + +### test_12_defender_triggers_first + +- Setup: Summon `Acolyte of Pain` for player 0 (id A), then `Acolyte of Pain` for player 1 (id D). The attacker, A, is older. +- Action 1: `orderAttack(A, D)`. +- Assertions 1: + - Call the card in `hand(0)` C0 and the card in `hand(1)` C1. + - The log is exactly, in this order: `Damaged{target: A, amount: 1}`, `Damaged{target: D, amount: 1}`, `Triggered{source: D, trigger: "damaged"}`, `Drew{player: 1, id: C1}`, `Triggered{source: A, trigger: "damaged"}`, `Drew{player: 0, id: C0}`. + - The `Damaged` events are in order of play, but the defender's trigger goes first. In order of play alone, A's would. +- Action 2: `clearLog()`, then `orderAttack(D, A)`: now the attacker is the younger one. +- Assertions 2: + - The sources of the `Triggered` events, in order, are A, D: the defender's first again. + - `health(A)` and `health(D)` are both 2. + +### test_12_taunt_restricts_targets + +- Setup: Summon `Loot Hoarder` for player 1 (id H), then `Unstable Ghoul` for player 1 (id T, Taunt), then `Harvest Golem` for player 0 (id A). +- Assertions 1: + - `canAttack(A, H)` is a string containing `Taunt`. + - `canAttack(A, hero(1))` is a string containing `Taunt`: Taunt protects the hero too. + - `canAttack(A, T)` is null. +- Action 1: `orderAttack(A, H)`. +- Assertions 2: it fails with `canAttack(A, H)`'s reason (TypeScript: it throws an `Error` with that message; Rust: it returns `Err` with that string). The log is empty and `health(H)` is 1: nothing happened. +- Action 2: `orderAttack(A, T)`. +- Assertions 3: the log is exactly `Damaged{target: T, amount: 2}`, `Damaged{target: A, amount: 1}` (order of play: T is older). `health(T)` is 1 and `health(A)` is 2. + +### test_12_zero_attack_cannot_attack + +- Setup: Summon `Test Bell` for player 0 (id B, 0 Attack), then `Test Filler` for player 0 (id F0), then `Test Filler` for player 1 (id F1). +- Assertions: + - `canAttack(B, F1)` is a string containing `Attack`, and `orderAttack(B, F1)` fails. + - `canAttack(F0, B)` is not null: a friendly minion can't be attacked. + - `canAttack(hero(0), F1)` is not null: in the course only minions attack. + - `canAttack(F0, F1)` and `canAttack(F0, hero(1))` are null. + - The log is empty. + +### test_12_attack_hero_through_armor + +- Setup: Summon `Armorsmith` for player 1 (id S). `dealDamage(S, 1)` twice, which gives player 1 2 Armor, then `destroy(S)`. Summon `Bolf Ramshield` for player 0 (id B, 3 Attack). `armor(1)` is 2. +- Action: `orderAttack(B, hero(1))`. +- Assertions: + - The log is exactly `ArmorLost{player: 1, amount: 2}`, `Damaged{target: hero(1), amount: 3}`. Combat damage to a hero goes through `DAMAGE_STEPS` like any other damage (Module 4). + - `armor(1)` is 0, `health(hero(1))` is 29. + - `health(B)` is 9: the hero dealt no damage back. + +### test_12_redirect_only_for_the_hero + +- Setup: Summon `Bolf Ramshield` for player 1 (id R), then `Loot Hoarder` for player 1 (id H), then `Harvest Golem` for player 0 (id A). +- Action 1: `orderAttack(A, H)`. +- Assertions 1: + - Call the card in `hand(1)` C. + - The log is exactly, in this order: `Damaged{target: H, amount: 2}`, `Damaged{target: A, amount: 2}`, `Destroyed{id: H}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 1, id: C}`. + - `health(R)` is 9: Bolf's redirect is for damage to its owner's hero, and this damage was aimed at a minion. +- Action 2: `clearLog()`, then `orderAttack(A, hero(1))`. +- Assertions 2: + - The log is exactly `Damaged{target: R, amount: 2}`: the redirect takes the attack's damage. + - `health(hero(1))` is 30, `health(R)` is 7. + - `health(A)` is 1: the Golem took no damage. Bolf took the damage in the hero's place, but Bolf was not the defender, so it deals nothing back. + +### test_12_end_of_turn_trigger + +- Setup: Summon `Test Bell` for player 0 (id B). +- Action 1: `endTurn(1)`. +- Assertions 1: the log is empty. The Bell answers only the end of its owner's turn. +- Action 2: `endTurn(0)`. +- Assertions 2: the log is exactly `Triggered{source: B, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`. `health(hero(1))` is 29 and `health(hero(0))` is 30. + +### test_12_drakkari_doubles_end_of_turn + +- Setup: Summon `Test Bell` for player 0 (id B0), then `Drakkari Enchanter` for player 0, then `Test Bell` for player 1 (id B1). +- Action 1: `endTurn(0)`. +- Assertions 1: the log is exactly `Triggered{source: B0, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`, `Triggered{source: B0, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`. The Bell's trigger is queued once and activates twice, back to back, as Baron's Deathrattles do. +- Action 2: `clearLog()`, then `endTurn(1)`. +- Assertions 2: the log is exactly `Triggered{source: B1, trigger: "endOfTurn"}`, `Damaged{target: hero(0), amount: 1}`. Drakkari doubles its owner's end of turn effects only. `health(hero(1))` is 28 and `health(hero(0))` is 29. + +### test_12_drakkari_summoned_at_end_of_turn + +`Test Conjurer` is registered in the test with `registerCards`: a minion, 1/1, `{trigger: endOfTurn}` then `{effect: summon "Drakkari Enchanter", count 1}`. + +- Case A, setup: Summon `Test Conjurer` for player 0 (id C), then `Test Bell` for player 0 (id B). +- Action: `endTurn(0)`. +- Assertions A: + - The log has exactly one `Summoned` event; call its id K. `nameOf(K)` is `"Drakkari Enchanter"`. + - The log is exactly, in this order: `Triggered{source: C, trigger: "endOfTurn"}`, `Summoned{id: K}`, `Triggered{source: B, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`, `Triggered{source: B, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`. + - The Conjurer's own entry counted its activations before Drakkari existed, so it activates once. A summon runs an Aura Update, so when the Bell's entry starts, Drakkari is in the snapshot and the Bell activates twice. +- Case B, a new game, setup: Summon `Test Bell` for player 0 (id B), then `Test Conjurer` for player 0 (id C). +- Action: `endTurn(0)`. +- Assertions B: + - The log has exactly one `Summoned` event; call its id K. + - The log is exactly, in this order: `Triggered{source: B, trigger: "endOfTurn"}`, `Damaged{target: hero(1), amount: 1}`, `Triggered{source: C, trigger: "endOfTurn"}`, `Summoned{id: K}`. + - `health(hero(1))` is 29. The Bell's entry started before any Drakkari existed. diff --git a/course/12-combat/tests/rust/test_12.rs b/course/12-combat/tests/rust/test_12.rs new file mode 100644 index 0000000..47b7e82 --- /dev/null +++ b/course/12-combat/tests/rust/test_12.rs @@ -0,0 +1,313 @@ +// Module 12: Combat. Each test matches a heading in scenarios.md. +use engine::harness::*; + +fn game_with_decks() -> Game { + new_game(GameOpts { + decks: Some([ + vec!["Test Filler".to_string(); 5], + vec!["Test Filler".to_string(); 5], + ]), + }) +} + +fn damaged(target: Id, amount: i32) -> GameEvent { + GameEvent::Damaged { target, amount } +} + +fn triggered(source: Id, trigger: &str) -> GameEvent { + GameEvent::Triggered { + source, + trigger: trigger.to_string(), + } +} + +fn card(json: &str) -> CardDef { + serde_json::from_str(json).unwrap() +} + +/// The `source` of each `Triggered` event, in log order. +fn triggered_sources(log: &[GameEvent]) -> Vec { + log.iter() + .filter_map(|e| match e { + GameEvent::Triggered { source, .. } => Some(*source), + _ => None, + }) + .collect() +} + +/// The id of the one `Summoned` event in a log. +fn summoned(log: &[GameEvent]) -> Id { + let ids: Vec = log + .iter() + .filter_map(|e| match e { + GameEvent::Summoned { id } => Some(*id), + _ => None, + }) + .collect(); + assert_eq!(ids.len(), 1, "{log:?}"); + ids[0] +} + +#[test] +fn test_12_combat_is_simultaneous() { + let mut g = game_with_decks(); + let a = g.summon("Loot Hoarder", 0); + let d = g.summon("Loot Hoarder", 1); + g.clear_log(); + + g.order_attack(a, d).unwrap(); + + let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]); + assert_eq!( + g.log(), + vec![ + damaged(a, 2), + damaged(d, 2), + GameEvent::Destroyed { id: a }, + GameEvent::Destroyed { id: d }, + GameEvent::DeathPhase { round: 1 }, + triggered(a, "deathrattle"), + GameEvent::Drew { player: 0, id: c0 }, + triggered(d, "deathrattle"), + GameEvent::Drew { player: 1, id: c1 }, + ] + ); + assert_eq!(g.zone_of(a), Zone::Graveyard); + assert_eq!(g.zone_of(d), Zone::Graveyard); +} + +#[test] +fn test_12_defender_triggers_first() { + let mut g = game_with_decks(); + let a = g.summon("Acolyte of Pain", 0); + let d = g.summon("Acolyte of Pain", 1); + g.clear_log(); + + // The attacker is older: order of play alone would put its trigger first. + g.order_attack(a, d).unwrap(); + + let (c0, c1) = (g.hand(0)[0], g.hand(1)[0]); + assert_eq!( + g.log(), + vec![ + damaged(a, 1), + damaged(d, 1), + triggered(d, "damaged"), + GameEvent::Drew { player: 1, id: c1 }, + triggered(a, "damaged"), + GameEvent::Drew { player: 0, id: c0 }, + ] + ); + + // The other way round: the attacker is younger, and the defender still goes first. + g.clear_log(); + g.order_attack(d, a).unwrap(); + assert_eq!(triggered_sources(&g.log()), vec![a, d]); + assert_eq!(g.health(a), 2); + assert_eq!(g.health(d), 2); +} + +#[test] +fn test_12_taunt_restricts_targets() { + let mut g = game_with_decks(); + let h = g.summon("Loot Hoarder", 1); + let t = g.summon("Unstable Ghoul", 1); + let a = g.summon("Harvest Golem", 0); + let enemy_hero = g.hero(1); + g.clear_log(); + + let reason = g.can_attack(a, h).expect("the Hoarder is behind a Taunt"); + assert!(reason.contains("Taunt"), "{reason}"); + let reason = g + .can_attack(a, enemy_hero) + .expect("the hero is behind a Taunt"); + assert!(reason.contains("Taunt"), "{reason}"); + assert_eq!(g.can_attack(a, t), None); + + assert_eq!(g.order_attack(a, h), Err(g.can_attack(a, h).unwrap())); + assert_eq!(g.log(), vec![]); + assert_eq!(g.health(h), 1); + + g.order_attack(a, t).unwrap(); + assert_eq!(g.log(), vec![damaged(t, 2), damaged(a, 1)]); + assert_eq!(g.health(t), 1); + assert_eq!(g.health(a), 2); +} + +#[test] +fn test_12_zero_attack_cannot_attack() { + let mut g = game_with_decks(); + let bell = g.summon("Test Bell", 0); + let f0 = g.summon("Test Filler", 0); + let f1 = g.summon("Test Filler", 1); + let (hero0, hero1) = (g.hero(0), g.hero(1)); + g.clear_log(); + + let reason = g.can_attack(bell, f1).expect("0 Attack"); + assert!(reason.contains("Attack"), "{reason}"); + assert!(g.order_attack(bell, f1).is_err()); + assert!(g.can_attack(f0, bell).is_some()); + assert!(g.can_attack(hero0, f1).is_some()); + assert_eq!(g.can_attack(f0, f1), None); + assert_eq!(g.can_attack(f0, hero1), None); + assert_eq!(g.log(), vec![]); +} + +#[test] +fn test_12_attack_hero_through_armor() { + let mut g = game_with_decks(); + let s = g.summon("Armorsmith", 1); + g.deal_damage(s, 1); + g.deal_damage(s, 1); + g.destroy(s); + let b = g.summon("Bolf Ramshield", 0); + let enemy_hero = g.hero(1); + assert_eq!(g.armor(1), 2); + g.clear_log(); + + g.order_attack(b, enemy_hero).unwrap(); + + assert_eq!( + g.log(), + vec![ + GameEvent::ArmorLost { + player: 1, + amount: 2 + }, + damaged(enemy_hero, 3), + ] + ); + assert_eq!(g.armor(1), 0); + assert_eq!(g.health(enemy_hero), 29); + assert_eq!(g.health(b), 9); +} + +#[test] +fn test_12_redirect_only_for_the_hero() { + let mut g = game_with_decks(); + let r = g.summon("Bolf Ramshield", 1); + let h = g.summon("Loot Hoarder", 1); + let a = g.summon("Harvest Golem", 0); + let enemy_hero = g.hero(1); + g.clear_log(); + + g.order_attack(a, h).unwrap(); + + let c = g.hand(1)[0]; + assert_eq!( + g.log(), + vec![ + damaged(h, 2), + damaged(a, 2), + GameEvent::Destroyed { id: h }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 1, id: c }, + ] + ); + assert_eq!(g.health(r), 9); + + g.clear_log(); + g.order_attack(a, enemy_hero).unwrap(); + + assert_eq!(g.log(), vec![damaged(r, 2)]); + assert_eq!(g.health(enemy_hero), 30); + assert_eq!(g.health(r), 7); + assert_eq!(g.health(a), 1); +} + +#[test] +fn test_12_end_of_turn_trigger() { + let mut g = game_with_decks(); + let b = g.summon("Test Bell", 0); + let (hero0, hero1) = (g.hero(0), g.hero(1)); + g.clear_log(); + + g.end_turn(1); + assert_eq!(g.log(), vec![]); + + g.end_turn(0); + assert_eq!(g.log(), vec![triggered(b, "endOfTurn"), damaged(hero1, 1)]); + assert_eq!(g.health(hero1), 29); + assert_eq!(g.health(hero0), 30); +} + +#[test] +fn test_12_drakkari_doubles_end_of_turn() { + let mut g = game_with_decks(); + let b0 = g.summon("Test Bell", 0); + g.summon("Drakkari Enchanter", 0); + let b1 = g.summon("Test Bell", 1); + let (hero0, hero1) = (g.hero(0), g.hero(1)); + g.clear_log(); + + g.end_turn(0); + assert_eq!( + g.log(), + vec![ + triggered(b0, "endOfTurn"), + damaged(hero1, 1), + triggered(b0, "endOfTurn"), + damaged(hero1, 1), + ] + ); + + // Drakkari doubles its owner's end of turn effects only. + g.clear_log(); + g.end_turn(1); + assert_eq!(g.log(), vec![triggered(b1, "endOfTurn"), damaged(hero0, 1)]); + assert_eq!(g.health(hero1), 28); + assert_eq!(g.health(hero0), 29); +} + +/// "At the end of your turn, summon a Drakkari Enchanter." +const TEST_CONJURER: &str = r#"{ "name": "Test Conjurer", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "endOfTurn" }, { "kind": "effect", "effect": { "type": "summon", "card": "Drakkari Enchanter", "count": 1 } }] }"#; + +#[test] +fn test_12_drakkari_summoned_at_end_of_turn() { + // Conjurer first: the Bell's entry starts after Drakkari's summon updated the auras. + let mut g = game_with_decks(); + g.register_cards(vec![card(TEST_CONJURER)]); + let c = g.summon("Test Conjurer", 0); + let b = g.summon("Test Bell", 0); + let hero1 = g.hero(1); + g.clear_log(); + + g.end_turn(0); + + let k = summoned(&g.log()); + assert_eq!(g.name_of(k), "Drakkari Enchanter"); + assert_eq!( + g.log(), + vec![ + triggered(c, "endOfTurn"), + GameEvent::Summoned { id: k }, + triggered(b, "endOfTurn"), + damaged(hero1, 1), + triggered(b, "endOfTurn"), + damaged(hero1, 1), + ] + ); + + // Bell first: its entry has started, and counted, before any Drakkari exists. + let mut g = game_with_decks(); + g.register_cards(vec![card(TEST_CONJURER)]); + let b = g.summon("Test Bell", 0); + let c = g.summon("Test Conjurer", 0); + let hero1 = g.hero(1); + g.clear_log(); + + g.end_turn(0); + + let k = summoned(&g.log()); + assert_eq!( + g.log(), + vec![ + triggered(b, "endOfTurn"), + damaged(hero1, 1), + triggered(c, "endOfTurn"), + GameEvent::Summoned { id: k }, + ] + ); + assert_eq!(g.health(hero1), 29); +} diff --git a/course/12-combat/tests/ts/test_12.test.ts b/course/12-combat/tests/ts/test_12.test.ts new file mode 100644 index 0000000..74d50fa --- /dev/null +++ b/course/12-combat/tests/ts/test_12.test.ts @@ -0,0 +1,255 @@ +// Module 12: Combat. Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newGame, type CardDef, type GameEvent, type Id } from "../src/harness"; + +const fillers = (n: number): string[] => Array(n).fill("Test Filler"); +const gameWithDecks = () => newGame({ decks: [fillers(5), fillers(5)] }); + +/** The `source` of each `Triggered` event, in log order. */ +const triggeredSources = (log: GameEvent[]): Id[] => log.flatMap((e) => (e.type === "Triggered" ? [e.source] : [])); + +/** The id of the one `Summoned` event in a log. */ +const summoned = (log: GameEvent[]): Id => { + const ids = log.flatMap((e) => (e.type === "Summoned" ? [e.id] : [])); + expect(ids).toHaveLength(1); + return ids[0]; +}; + +test("test_12_combat_is_simultaneous", () => { + const g = gameWithDecks(); + const a = g.summon("Loot Hoarder", 0); + const d = g.summon("Loot Hoarder", 1); + g.clearLog(); + + g.orderAttack(a, d); + + const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]]; + expect(g.log()).toEqual([ + { type: "Damaged", target: a, amount: 2 }, + { type: "Damaged", target: d, amount: 2 }, + { type: "Destroyed", id: a }, + { type: "Destroyed", id: d }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: a, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c0 }, + { type: "Triggered", source: d, trigger: "deathrattle" }, + { type: "Drew", player: 1, id: c1 }, + ]); + expect(g.zoneOf(a)).toBe("graveyard"); + expect(g.zoneOf(d)).toBe("graveyard"); +}); + +test("test_12_defender_triggers_first", () => { + const g = gameWithDecks(); + const a = g.summon("Acolyte of Pain", 0); + const d = g.summon("Acolyte of Pain", 1); + g.clearLog(); + + // The attacker is older: order of play alone would put its trigger first. + g.orderAttack(a, d); + + const [c0, c1] = [g.hand(0)[0], g.hand(1)[0]]; + expect(g.log()).toEqual([ + { type: "Damaged", target: a, amount: 1 }, + { type: "Damaged", target: d, amount: 1 }, + { type: "Triggered", source: d, trigger: "damaged" }, + { type: "Drew", player: 1, id: c1 }, + { type: "Triggered", source: a, trigger: "damaged" }, + { type: "Drew", player: 0, id: c0 }, + ]); + + // The other way round: the attacker is younger, and the defender still goes first. + g.clearLog(); + g.orderAttack(d, a); + expect(triggeredSources(g.log())).toEqual([a, d]); + expect(g.health(a)).toBe(2); + expect(g.health(d)).toBe(2); +}); + +test("test_12_taunt_restricts_targets", () => { + const g = gameWithDecks(); + const h = g.summon("Loot Hoarder", 1); + const t = g.summon("Unstable Ghoul", 1); + const a = g.summon("Harvest Golem", 0); + const enemyHero = g.hero(1); + g.clearLog(); + + expect(g.canAttack(a, h)).toContain("Taunt"); + expect(g.canAttack(a, enemyHero)).toContain("Taunt"); + expect(g.canAttack(a, t)).toBeNull(); + + expect(() => g.orderAttack(a, h)).toThrow(g.canAttack(a, h)!); + expect(g.log()).toEqual([]); + expect(g.health(h)).toBe(1); + + g.orderAttack(a, t); + expect(g.log()).toEqual([ + { type: "Damaged", target: t, amount: 2 }, + { type: "Damaged", target: a, amount: 1 }, + ]); + expect(g.health(t)).toBe(1); + expect(g.health(a)).toBe(2); +}); + +test("test_12_zero_attack_cannot_attack", () => { + const g = gameWithDecks(); + const bell = g.summon("Test Bell", 0); + const f0 = g.summon("Test Filler", 0); + const f1 = g.summon("Test Filler", 1); + const [hero0, hero1] = [g.hero(0), g.hero(1)]; + g.clearLog(); + + expect(g.canAttack(bell, f1)).toContain("Attack"); + expect(() => g.orderAttack(bell, f1)).toThrow(); + expect(g.canAttack(f0, bell)).not.toBeNull(); + expect(g.canAttack(hero0, f1)).not.toBeNull(); + expect(g.canAttack(f0, f1)).toBeNull(); + expect(g.canAttack(f0, hero1)).toBeNull(); + expect(g.log()).toEqual([]); +}); + +test("test_12_attack_hero_through_armor", () => { + const g = gameWithDecks(); + const s = g.summon("Armorsmith", 1); + g.dealDamage(s, 1); + g.dealDamage(s, 1); + g.destroy(s); + const b = g.summon("Bolf Ramshield", 0); + const enemyHero = g.hero(1); + expect(g.armor(1)).toBe(2); + g.clearLog(); + + g.orderAttack(b, enemyHero); + + expect(g.log()).toEqual([ + { type: "ArmorLost", player: 1, amount: 2 }, + { type: "Damaged", target: enemyHero, amount: 3 }, + ]); + expect(g.armor(1)).toBe(0); + expect(g.health(enemyHero)).toBe(29); + expect(g.health(b)).toBe(9); +}); + +test("test_12_redirect_only_for_the_hero", () => { + const g = gameWithDecks(); + const r = g.summon("Bolf Ramshield", 1); + const h = g.summon("Loot Hoarder", 1); + const a = g.summon("Harvest Golem", 0); + const enemyHero = g.hero(1); + g.clearLog(); + + g.orderAttack(a, h); + + const c = g.hand(1)[0]; + expect(g.log()).toEqual([ + { type: "Damaged", target: h, amount: 2 }, + { type: "Damaged", target: a, amount: 2 }, + { type: "Destroyed", id: h }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 1, id: c }, + ]); + expect(g.health(r)).toBe(9); + + g.clearLog(); + g.orderAttack(a, enemyHero); + + expect(g.log()).toEqual([{ type: "Damaged", target: r, amount: 2 }]); + expect(g.health(enemyHero)).toBe(30); + expect(g.health(r)).toBe(7); + expect(g.health(a)).toBe(1); +}); + +test("test_12_end_of_turn_trigger", () => { + const g = gameWithDecks(); + const b = g.summon("Test Bell", 0); + const [hero0, hero1] = [g.hero(0), g.hero(1)]; + g.clearLog(); + + g.endTurn(1); + expect(g.log()).toEqual([]); + + g.endTurn(0); + expect(g.log()).toEqual([ + { type: "Triggered", source: b, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + ]); + expect(g.health(hero1)).toBe(29); + expect(g.health(hero0)).toBe(30); +}); + +test("test_12_drakkari_doubles_end_of_turn", () => { + const g = gameWithDecks(); + const b0 = g.summon("Test Bell", 0); + g.summon("Drakkari Enchanter", 0); + const b1 = g.summon("Test Bell", 1); + const [hero0, hero1] = [g.hero(0), g.hero(1)]; + g.clearLog(); + + g.endTurn(0); + expect(g.log()).toEqual([ + { type: "Triggered", source: b0, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + { type: "Triggered", source: b0, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + ]); + + // Drakkari doubles its owner's end of turn effects only. + g.clearLog(); + g.endTurn(1); + expect(g.log()).toEqual([ + { type: "Triggered", source: b1, trigger: "endOfTurn" }, + { type: "Damaged", target: hero0, amount: 1 }, + ]); + expect(g.health(hero1)).toBe(28); + expect(g.health(hero0)).toBe(29); +}); + +/** "At the end of your turn, summon a Drakkari Enchanter." A one-off card, written as data in the test. */ +const TEST_CONJURER = { "name": "Test Conjurer", "type": "minion", "cost": 1, "attack": 1, "health": 1, "split": 1, "text": [{ "kind": "trigger", "on": "endOfTurn" }, { "kind": "effect", "effect": { "type": "summon", "card": "Drakkari Enchanter", "count": 1 } }] } as CardDef; + +test("test_12_drakkari_summoned_at_end_of_turn", () => { + // Conjurer first: the Bell's entry starts after Drakkari's summon updated the auras. + { + const g = gameWithDecks(); + g.registerCards([TEST_CONJURER]); + const c = g.summon("Test Conjurer", 0); + const b = g.summon("Test Bell", 0); + const hero1 = g.hero(1); + g.clearLog(); + + g.endTurn(0); + + const k = summoned(g.log()); + expect(g.nameOf(k)).toBe("Drakkari Enchanter"); + expect(g.log()).toEqual([ + { type: "Triggered", source: c, trigger: "endOfTurn" }, + { type: "Summoned", id: k }, + { type: "Triggered", source: b, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + { type: "Triggered", source: b, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + ]); + } + + // Bell first: its entry has started, and counted, before any Drakkari exists. + { + const g = gameWithDecks(); + g.registerCards([TEST_CONJURER]); + const b = g.summon("Test Bell", 0); + const c = g.summon("Test Conjurer", 0); + const hero1 = g.hero(1); + g.clearLog(); + + g.endTurn(0); + + const k = summoned(g.log()); + expect(g.log()).toEqual([ + { type: "Triggered", source: b, trigger: "endOfTurn" }, + { type: "Damaged", target: hero1, amount: 1 }, + { type: "Triggered", source: c, trigger: "endOfTurn" }, + { type: "Summoned", id: k }, + ]); + expect(g.health(hero1)).toBe(29); + } +}); diff --git a/course/catalog.json b/course/catalog.json index 76d3ecc..e8c79bf 100644 --- a/course/catalog.json +++ b/course/catalog.json @@ -126,6 +126,26 @@ "text": "Your end of turn effects trigger twice.", "source": "https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter" }, + { + "name": "Test Bell", + "kind": "card", + "type": "minion", + "cost": 1, + "attack": 0, + "health": 3, + "text": "At the end of your turn, deal 1 damage to the enemy hero.", + "fictional": true + }, + { + "name": "Bonechewer Brawler", + "kind": "card", + "type": "minion", + "cost": 2, + "attack": 2, + "health": 3, + "text": "Taunt. Whenever this minion takes damage, gain +2 Attack.", + "source": "https://hearthstone.wiki.gg/wiki/Bonechewer_Brawler" + }, { "name": "Novice Engineer", "kind": "card", diff --git a/course/harness.md b/course/harness.md index 7839f1d..0bf8c7a 100644 --- a/course/harness.md +++ b/course/harness.md @@ -13,6 +13,7 @@ Each module adds functions; `start-module` prints the line for the module you st - M3: `destroy(target: Id): void` (marks pending-destroy, one Sequence). - M4: no new functions; `play(handId)` (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (`Summoned{id}`), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). The M4 tests also use `Baron Rivendare`, `Novice Engineer`, `Bolf Ramshield` and `Twisting Nether` (the M3 Expansion Drop card). New event `ArmorLost{player, amount}` (see `GameEvent`). - M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `" (Assembled)"`; throws `AssembleError` if the text is unchanged or not well-formed); `cost(id): number`; `setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void` (makes Assemble's choices). `CardDef` is the card format of `course/schema/card.schema.json`; `newGame` loads every card file in the workspace's `cards/` directory. +- M12: `orderAttack(attacker: Id, defender: Id): void` (one Sequence; throws an `Error` whose message is `canAttack`'s reason if the attack is illegal, and changes nothing); `canAttack(attacker: Id, defender: Id): string | null` (why the attack is illegal, or `null`); `endTurn(player: Player): void` (one Sequence: `player`'s end of turn triggers resolve). The M12 tests also use `Test Bell` (`course/12-combat/cards/`) and `Drakkari Enchanter`. The name is `orderAttack` because `attack(id)` (M2) already reads a character's Attack. ### Side by side (Part I) @@ -39,6 +40,9 @@ Each module adds functions; `start-module` prints the line for the module you st | M5 | `assemble(base, donor, half)` (throws `AssembleError`) | `assemble(&mut self, base: &str, donor: &str, half: Half) -> Result` (`Half::Top` / `Half::Bottom`) | | M5 | `cost(id)` | `cost(&self, id: Id) -> i32` | | M5 | `setChooser(choose)` | `set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)` | +| M12 | `orderAttack(attacker, defender)` (throws `Error`) | `order_attack(&mut self, attacker: Id, defender: Id) -> Result<(), String>` | +| M12 | `canAttack(attacker, defender): string \| null` | `can_attack(&self, attacker: Id, defender: Id) -> Option` | +| M12 | `endTurn(player)` | `end_turn(&mut self, player: Player)` | From Module 5, Rust's `CardDef` must implement `serde::Deserialize` for the card format: the tests parse `cards/*.json` with `serde_json` (add `serde` with the `derive` feature and `serde_json` to `Cargo.toml`). The TypeScript tests import the files with Vite's `import.meta.glob`. @@ -46,6 +50,8 @@ From Module 5, Assemble's result must be well-formed. For a minion: every effect From Module 5, a Sequence is bounded. A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. More than 64 Death Phases in one Sequence is an error: the call that started the Sequence throws an `Error` (TypeScript) or panics (Rust), with a message containing `Death Phase limit`. A `summon` effect with `count` greater than 1 places its tokens left to right in summon order. +From Module 12, an attack is one Sequence. It is legal when the attacker is a minion in play, the defender is an enemy minion in play or the enemy hero, the attacker's Attack is more than 0, and, while the defender's side has a minion with the keyword `taunt`, the defender is such a minion. The reason `canAttack` gives contains `Taunt` when Taunt is what forbids the attack, and `Attack` when 0 Attack does. The attacker deals its Attack to the defender and the defender deals its Attack to the attacker at the same time: one Damage action with two targets, each with its own amount, its `Damaged` events in order of play like any area damage. A hero has no Attack and deals nothing back, and neither does a 0 Attack minion. The triggers answering the defender's `Damaged` event are queued before those answering the attacker's, whatever their order of play; otherwise the Queue is as before. Combat damage goes through Armor and redirects like any damage. `endTurn(player)` raises the end of `player`'s turn: each trigger of that player's minions listening for `endOfTurn` responds, in order of play (`Triggered{source, trigger: "endOfTurn"}`), and a trigger multiplier for `endOfTurn` applies as for any trigger. The course has no mana, no limit on attacks per turn and no summoning sickness. + ### `GameEvent` Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, trigger}` (`trigger`: `"deathrattle"|"battlecry"|"damaged"|...`), `Destroyed{id}` (removed in a Death Creation Step), `DeathPhase{round}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `ArmorLost{player, amount}` (from M4), `StatsChanged{id, attack, health}`, `SpellCast{card}`. diff --git a/docs/research/hearthstone.md b/docs/research/hearthstone.md index ab53383..d2b224d 100644 --- a/docs/research/hearthstone.md +++ b/docs/research/hearthstone.md @@ -271,3 +271,70 @@ Fetched with WebFetch, which returns quotes of 125 characters or fewer, so the q - "Damage taken will always be deducted from Armor before Health." - "Damage taken while protected by Armor still counts as taking damage for game purposes." - The page does not mention Bolf Ramshield or redirection. + +## Source 6: Combat, Taunt, end of turn (fetched 2026-11-11, course v2 Task 9) + +Fetched with WebFetch, which returns quotes of 125 characters or fewer, so the quotes below are short verbatim excerpts. The Advanced rulebook is about 480k characters; WebFetch read it in 100k chunks, and the combat section was in the chunk from character 100,000 to 200,000. The tool did not report the rulebook's last-edited date this time. + +| Source | Result | +|---|---| +| https://hearthstone.wiki.gg/wiki/Advanced_rulebook | Loaded (characters 0 to 200,000 read for this task). | +| https://hearthstone.wiki.gg/wiki/Attack | Loaded. Last edited 18 July 2024. | +| https://hearthstone.wiki.gg/wiki/Taunt | Loaded. Last edited 11 August 2025. | +| https://hearthstone.wiki.gg/wiki/Drakkari_Enchanter | Loaded. Last edited 2 January 2026. | +| https://hearthstone.wiki.gg/wiki/Bonechewer_Brawler | Loaded. Last edited 4 October 2026. | +| https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/ | Loaded (re-fetched for the `priority` field). | +| https://hearthstone.wiki.gg/wiki/Bolf_Ramshield | Loaded again, for what it says about attacks. | + +**Advanced rulebook, Combat.** +- "When you order a minion to attack another minion, the following Sequence takes place:" +- Sequences: "Sending a Character to attack another character creates a Sequence of two Phases - "Preparation" and "Attack"." +- Combat Preparation Phase: "A Proposed Attack Event is resolved." Then "Finally, an Attack Event is resolved." +- Combat Phase: "Damage is dealt simultaneously in the order (attack, counterattack) and resolved." +- "The subjects of Combat are the attacker and defender." +- After Attack Event: "An After Attack Event is resolved as long as the attack was successful, even if it dealt 0 damage." +- Ordering of damage in the Combat Phase, damage taken: "If both minions trigger when they take damage (such as a Grim Patron and an Imp Gang Boss), the defender" / "ALWAYS triggers first, regardless of order of play." +- Same section, damage dealt: "If both minions trigger when they deal damage (such as Queen of Pain and Poison), the attacker" / "ALWAYS triggers first, regardless of order of play." +- Paraphrased example from the same section: your Grim Patron attacks an enemy Imp Gang Boss; the defender queues first, so the Imp Gang Boss's trigger resolves first. +- Not found in the chunks read: a statement that a 0 Attack minion cannot attack, the Taunt rule, attacks per turn. Those come from the Attack and Taunt pages below. + +**Advanced rulebook, Start and end of turn.** +- "All 'end of turn' Triggers (as well as the special case Shadow Madness) are Queued and resolved." +- Notes: "Enchantments that trigger at the start or end of turn Queue in order of play alongside typical triggers." +- Rule 4 notes (already in Source 1): Drakkari Enchanter is among the cards that "check before an Event starts", and "Deathrattles/End of turn effects that follow can benefit, for Aura (Other) is updated when you summon them." + +**Attack.** +- Attackers: "Characters with 0 Attack cannot be ordered to attack." +- Choosing an attacker: "A character with an Attack of 0 (like Shieldbearer and most heroes) cannot attack." +- Combat: "The attacker and defender deal damage to each other at the same time..." (the tool's excerpt ends with the ellipsis). +- Attackers: "...heroes by default have no Attack value." Overview: "Therefore, enemy minions can hit the hero without harm during the opponent's turn." +- Choosing an attacker: "A character can usually attack up to one time per turn, with the following exceptions:" The course doesn't model this. +- The page also names "summoning sickness". The course doesn't model it. + +**Taunt.** +- "Enemies must attack minions that have Taunt." +- Players "cannot attack any other characters while there are enemy Taunt minions on the battlefield." +- "Taunts have no effect on spells, Hero Powers or other damaging effects." +- "If the opponent has more than one minion with Taunt, the player will be able to choose which one of them to attack." +- Stealth: "The Taunt effect is temporarily negated by the Stealth or Immunity." The course has no Stealth. + +**Drakkari Enchanter.** 3-mana 1/5 Epic, Knights of the Frozen Throne. +- Text: "Your end of turn effects trigger twice." +- "This card affects most end-of-turn effects." and "Drakkari Enchanter doesn't affect some end of turn effects." +- The page says nothing about two Drakkaris, or about a Drakkari summoned during the end of a turn. The rulebook's Rule 4 note covers the second. + +**Bonechewer Brawler.** 2-mana 2/3 Common, Neutral, Ashes of Outland, Fel orc. +- Text: "Taunt. Whenever this minion takes damage, gain +2 Attack." +- Expressible in the course's card format with existing words: keyword `taunt`, trigger `selfDamaged`, effect `gainAttack` on `self`. Used as Module 12's Expansion Drop. + +**Bolf Ramshield and attacks.** +- Notes: "Thus, any effects which trigger from the attacking character dealing damage, such as Snowchugger and Queen of Pain" / "will still take effect if Bolf Ramshield intercepts the damage." So Bolf intercepts attack damage aimed at his hero. +- The page says nothing about Bolf dealing damage back to the attacker, and nothing about weapons. The course's choice: no counterattack from a redirect. + +**Liquid Fire, Make a CCG – Action System.** +- Field: "public int priority { get; set; }" +- "The “priority” is most important and is a way to override the default sorting in “orderOfPlay”" +- "The final result will order them first by “priority” in descending order (higher priority goes first)" +- `SortActions`: "if (x.priority != y.priority) {" / "return y.priority.CompareTo(x.priority);" / "} else {" / "return x.orderOfPlay.CompareTo(y.orderOfPlay);" + +**Course use (Module 12).** The defender-first rule is modelled as a priority on the queue entries that answer the defender's Damage Event; the attacker-first rule for "deal damage" triggers is not modelled (the course has no trigger on dealing damage). The rulebook's "(attack, counterattack)" order of the damage is not followed for the `Damaged` events: the course creates them in order of play, as for all area damage, and says so in the lesson.