From bcdaca0cd2b9a7eb7f3254eaecda243b81b65679 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 02:54:37 +0100 Subject: [PATCH] feat: module 04 interceptors Trigger multipliers as aura data on cards, snapshotted by an Aura Update (Other) after each Death Creation Step and on summon, and applied by one generic routine (activations) when each queued trigger starts. Baron Rivendare is content only (stacking "max"); Brann Bronzebeard is the content-only Expansion Drop. Adds play (now required) and Battlecry. Co-Authored-By: Claude Opus 5.5 --- course/04-interceptors/expansion/README.md | 21 + .../solution/rust/src/content/mod.rs | 90 + .../solution/ts/src/content/index.ts | 61 + .../expansion/tests/rust/test_04x.rs | 33 + .../expansion/tests/ts/test_04x.test.ts | 24 + course/04-interceptors/lesson.md | 363 ++++ .../reference/rust/.course.json | 1 + .../04-interceptors/reference/rust/.gitignore | 1 + .../04-interceptors/reference/rust/Cargo.toml | 7 + .../04-interceptors/reference/rust/README.md | 15 + .../reference/rust/src/content/mod.rs | 84 + .../reference/rust/src/engine/aura.rs | 53 + .../reference/rust/src/engine/card.rs | 133 ++ .../reference/rust/src/engine/death.rs | 30 + .../reference/rust/src/engine/event.rs | 51 + .../reference/rust/src/engine/mod.rs | 28 + .../reference/rust/src/engine/queue.rs | 259 +++ .../reference/rust/src/engine/world.rs | 315 ++++ .../reference/rust/src/harness.rs | 157 ++ .../04-interceptors/reference/rust/src/lib.rs | 3 + .../04-interceptors/reference/ts/.course.json | 1 + .../04-interceptors/reference/ts/.gitignore | 1 + course/04-interceptors/reference/ts/README.md | 15 + .../reference/ts/package-lock.json | 1496 +++++++++++++++++ .../04-interceptors/reference/ts/package.json | 14 + .../reference/ts/src/content/index.ts | 57 + .../reference/ts/src/engine/aura.ts | 42 + .../reference/ts/src/engine/card.ts | 91 + .../reference/ts/src/engine/death.ts | 21 + .../reference/ts/src/engine/event.ts | 22 + .../reference/ts/src/engine/index.ts | 31 + .../reference/ts/src/engine/queue.ts | 201 +++ .../reference/ts/src/engine/world.ts | 244 +++ .../reference/ts/src/harness.ts | 90 + .../reference/ts/tsconfig.json | 12 + .../reference/ts/vitest.config.ts | 5 + course/04-interceptors/scenarios.md | 61 + course/04-interceptors/tests/rust/test_04.rs | 163 ++ .../04-interceptors/tests/ts/test_04.test.ts | 122 ++ course/harness.md | 4 +- 40 files changed, 4421 insertions(+), 1 deletion(-) create mode 100644 course/04-interceptors/expansion/README.md create mode 100644 course/04-interceptors/expansion/solution/rust/src/content/mod.rs create mode 100644 course/04-interceptors/expansion/solution/ts/src/content/index.ts create mode 100644 course/04-interceptors/expansion/tests/rust/test_04x.rs create mode 100644 course/04-interceptors/expansion/tests/ts/test_04x.test.ts create mode 100644 course/04-interceptors/lesson.md create mode 100644 course/04-interceptors/reference/rust/.course.json create mode 100644 course/04-interceptors/reference/rust/.gitignore create mode 100644 course/04-interceptors/reference/rust/Cargo.toml create mode 100644 course/04-interceptors/reference/rust/README.md create mode 100644 course/04-interceptors/reference/rust/src/content/mod.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/aura.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/card.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/death.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/event.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/mod.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/queue.rs create mode 100644 course/04-interceptors/reference/rust/src/engine/world.rs create mode 100644 course/04-interceptors/reference/rust/src/harness.rs create mode 100644 course/04-interceptors/reference/rust/src/lib.rs create mode 100644 course/04-interceptors/reference/ts/.course.json create mode 100644 course/04-interceptors/reference/ts/.gitignore create mode 100644 course/04-interceptors/reference/ts/README.md create mode 100644 course/04-interceptors/reference/ts/package-lock.json create mode 100644 course/04-interceptors/reference/ts/package.json create mode 100644 course/04-interceptors/reference/ts/src/content/index.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/aura.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/card.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/death.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/event.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/index.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/queue.ts create mode 100644 course/04-interceptors/reference/ts/src/engine/world.ts create mode 100644 course/04-interceptors/reference/ts/src/harness.ts create mode 100644 course/04-interceptors/reference/ts/tsconfig.json create mode 100644 course/04-interceptors/reference/ts/vitest.config.ts create mode 100644 course/04-interceptors/scenarios.md create mode 100644 course/04-interceptors/tests/rust/test_04.rs create mode 100644 course/04-interceptors/tests/ts/test_04.test.ts diff --git a/course/04-interceptors/expansion/README.md b/course/04-interceptors/expansion/README.md new file mode 100644 index 0000000..02e2539 --- /dev/null +++ b/course/04-interceptors/expansion/README.md @@ -0,0 +1,21 @@ +# Module 4 Expansion Drop: Brann Bronzebeard + +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: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." ([Brann Bronzebeard, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). +- Tests: `tests/rust/test_04x.rs` and `tests/ts/test_04x.test.ts`. Copy the one for your track into your workspace's `tests/`. +- Reference answer: `solution/rust/src/content/mod.rs` and `solution/ts/src/content/index.ts`. Each is the Module 4 reference content with one card added. Nothing under `src/engine/` changes, and neither does the harness. +- Check: commit your workspace before you start, then run `node tools/expansion-check.mjs ` from the course repository. It fails if any file under `src/engine/` changed. + +The reference gives Brann the same stacking policy as Baron Rivendare (`"max"`). The course's sources don't state how two Branns combine, and this test uses one Brann, so the choice isn't checked. + +## Scenario + +### test_04x_brann_doubles_battlecry + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Brann Bronzebeard` for player 0 (id B). Add `Novice Engineer` ("Battlecry: Draw a card.") to player 0's hand (id E). Call `clearLog()`. +- Action: `play(E)`. +- Assertions: + - `hand(0)` has 2 cards; call them C1 and C2, in hand order. + - The log is exactly, in this order: `Summoned{id: E}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C1}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C2}`. The Battlecry is queued once and activates twice back to back, exactly as a Deathrattle does under Baron Rivendare. + - `board(0)` is `[B, E]`. diff --git a/course/04-interceptors/expansion/solution/rust/src/content/mod.rs b/course/04-interceptors/expansion/solution/rust/src/content/mod.rs new file mode 100644 index 0000000..fca0c77 --- /dev/null +++ b/course/04-interceptors/expansion/solution/rust/src/content/mod.rs @@ -0,0 +1,90 @@ +//! Content: card definitions. Only this directory may name specific cards. +//! +//! Each effect is a small Rust function that returns engine actions. Module 5 +//! replaces these functions with card data the engine interprets. Auras are +//! already data: the engine reads them, and no engine code knows these cards. + +use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, TriggerOn}; + +/// Every card this game knows. +pub fn library() -> CardLibrary { + CardLibrary::new(vec![ + // Fictional test minion with no text. + CardDef::minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + CardDef::minion("Armorsmith", 1, 4).on(TriggerOn::FriendlyMinionDamaged, |c| { + vec![Action::GainArmor { + player: c.owner, + amount: 1, + }] + }), + // "Whenever this minion takes damage, draw a card." + CardDef::minion("Acolyte of Pain", 1, 4).on(TriggerOn::SelfDamaged, |c| { + vec![Action::Draw { player: c.owner }] + }), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + CardDef::minion("Test Echo", 1, 5).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::Damage { + targets: vec![c.enemy_hero()], + amount: 1, + }] + }), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + CardDef::minion("Test Reactor", 1, 5).on(TriggerOn::HeroDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] + }), + // "Deathrattle: Draw a card." + CardDef::minion("Loot Hoarder", 2, 1).on(TriggerOn::Deathrattle, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deathrattle: Summon a 2/1 Damaged Golem." + CardDef::minion("Harvest Golem", 2, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Summon { + card: "Damaged Golem".to_string(), + player: c.owner, + at: c.position, + }] + }), + // Summoned by Harvest Golem; no text. + CardDef::minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + CardDef::minion("Unstable Ghoul", 1, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Your minions trigger their Deathrattles twice." Two Barons still only double. + CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier { + trigger: TriggerOn::Deathrattle, + times: 2, + stacking: Stacking::Max, + }), + // Expansion Drop: "Your Battlecries trigger twice." + CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier { + trigger: TriggerOn::Battlecry, + times: 2, + stacking: Stacking::Max, + }), + // "Battlecry: Draw a card." + CardDef::minion("Novice Engineer", 1, 1).on(TriggerOn::Battlecry, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.) + CardDef::spell("Twisting Nether", |c| { + vec![Action::Destroy { + targets: c.world.minions_in_play(), + }] + }), + ]) +} diff --git a/course/04-interceptors/expansion/solution/ts/src/content/index.ts b/course/04-interceptors/expansion/solution/ts/src/content/index.ts new file mode 100644 index 0000000..27e6c67 --- /dev/null +++ b/course/04-interceptors/expansion/solution/ts/src/content/index.ts @@ -0,0 +1,61 @@ +// Content: card definitions. Only this directory may name specific cards. +// +// Each effect is a small function that returns engine actions. Module 5 +// replaces these functions with card data the engine interprets. Auras are +// already data: the engine reads them, and no engine code knows these cards. + +import { CardLibrary, minion, spell } from "../engine"; + +/** Every card this game knows. */ +export function library(): CardLibrary { + return new CardLibrary([ + // Fictional test minion with no text. + minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + minion("Armorsmith", 1, 4, [ + { on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] }, + ]), + // "Whenever this minion takes damage, draw a card." + minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + minion("Test Echo", 1, 5, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] }, + ]), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + minion("Test Reactor", 1, 5, [ + { on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, + ]), + // "Deathrattle: Draw a card." + minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deathrattle: Summon a 2/1 Damaged Golem." + minion("Harvest Golem", 2, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }], + }, + ]), + // Summoned by Harvest Golem; no text. + minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + minion("Unstable Ghoul", 1, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }], + }, + ]), + // "Your minions trigger their Deathrattles twice." Two Barons still only double. + minion("Baron Rivendare", 1, 7, [], [ + { type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" }, + ]), + // Expansion Drop: "Your Battlecries trigger twice." + minion("Brann Bronzebeard", 2, 4, [], [ + { type: "triggerMultiplier", trigger: "battlecry", times: 2, stacking: "max" }, + ]), + // "Battlecry: Draw a card." + minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + // "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.) + spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]), + ]); +} diff --git a/course/04-interceptors/expansion/tests/rust/test_04x.rs b/course/04-interceptors/expansion/tests/rust/test_04x.rs new file mode 100644 index 0000000..a3e8df0 --- /dev/null +++ b/course/04-interceptors/expansion/tests/rust/test_04x.rs @@ -0,0 +1,33 @@ +// Module 4 Expansion Drop: Brann Bronzebeard. Matches expansion/README.md. +use engine::harness::*; + +#[test] +fn test_04x_brann_doubles_battlecry() { + let mut g = new_game(GameOpts { + decks: Some([vec!["Test Filler".to_string(); 5], vec![]]), + }); + let brann = g.summon("Brann Bronzebeard", 0); + let e = g.add_to_hand("Novice Engineer", 0); + g.clear_log(); + + g.play(e); + + let hand = g.hand(0); + assert_eq!(hand.len(), 2); + let (c1, c2) = (hand[0], hand[1]); + let battlecry = GameEvent::Triggered { + source: e, + trigger: "battlecry".to_string(), + }; + assert_eq!( + g.log(), + vec![ + GameEvent::Summoned { id: e }, + battlecry.clone(), + GameEvent::Drew { player: 0, id: c1 }, + battlecry, + GameEvent::Drew { player: 0, id: c2 }, + ] + ); + assert_eq!(g.board(0), vec![brann, e]); +} diff --git a/course/04-interceptors/expansion/tests/ts/test_04x.test.ts b/course/04-interceptors/expansion/tests/ts/test_04x.test.ts new file mode 100644 index 0000000..2f72f8b --- /dev/null +++ b/course/04-interceptors/expansion/tests/ts/test_04x.test.ts @@ -0,0 +1,24 @@ +// Module 4 Expansion Drop: Brann Bronzebeard. Matches expansion/README.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +test("test_04x_brann_doubles_battlecry", () => { + const g = newGame({ decks: [Array(5).fill("Test Filler"), []] }); + const brann = g.summon("Brann Bronzebeard", 0); + const e = g.addToHand("Novice Engineer", 0); + g.clearLog(); + + g.play(e); + + const hand = g.hand(0); + expect(hand).toHaveLength(2); + const [c1, c2] = hand; + expect(g.log()).toEqual([ + { type: "Summoned", id: e }, + { type: "Triggered", source: e, trigger: "battlecry" }, + { type: "Drew", player: 0, id: c1 }, + { type: "Triggered", source: e, trigger: "battlecry" }, + { type: "Drew", player: 0, id: c2 }, + ]); + expect(g.board(0)).toEqual([brann, e]); +}); diff --git a/course/04-interceptors/lesson.md b/course/04-interceptors/lesson.md new file mode 100644 index 0000000..646eb91 --- /dev/null +++ b/course/04-interceptors/lesson.md @@ -0,0 +1,363 @@ +# Module 4: Interceptors and "trigger twice" + +In Module 0 you added Baron Rivendare to the naive engine. To do it, you edited the code that runs Deathrattles. In this module you add him again, as one entry in your content, and the code that runs Deathrattles stays as it is. The engine gains one general rule: a trigger may activate more than once, and the number comes from data on cards in play. At the end, a second "trigger twice" card arrives and you add it without touching the engine. + +You need your Module 3 workspace with all Module 1–3 tests passing, under git. This module's tests also use two things from earlier Expansion Drops: + +- `play(handId)` from [Module 1](../01-handles/lesson.md#expansion-drop). It is now required, and it does more than in Module 1: see Spec. +- Twisting Nether from [Module 3](../03-death-step/lesson.md#expansion-drop). + +If you skipped either drop, do it first. Each is small. + +## Observe + +Baron Rivendare, 4-mana 1/7 Legendary ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)): + +> Your minions trigger their Deathrattles twice. + +The card page adds the details that matter for an engine (same source): + +- **Queued once, activated twice.** "The two activations of the Deathrattle do not queue/resolve separately." "The second activation of the Deathrattle is immediately after the first activation resolves", before the next queued trigger. +- **Two Barons are not four.** "Rivendare's ability is not duplicated or multiplied by having more than one Baron Rivendare in play." +- **Dying with Baron.** "if he dies at the same time as a minion with a Deathrattle, the Deathrattle is not doubled." +- **Why.** "Baron Rivendare's effect, being an aura, only appears and disappears between Phases when Aura Update (Other) runs". + +The [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook) places that update. When the outermost Phase ends, "Hearthstone does an Aura Update (Health/Attack), then does the Death Creation Step", and an Aura Update (Other) follows the step. Baron is listed among the "Other" auras. The rulebook also says when auras are *not* recalculated: "Auras are not recalculated due to minions leaving play or due to minions being stolen in the middle of a Phase." It names one more update point: "whenever a minion is summoned (but not played), Hearthstone updates both kinds of Auras." And it groups Baron with other cards of the same kind: "Cards that trigger effects multiple times, like Brann Bronzebeard, Baron Rivendare and Drakkari Enchanter, check before an Event starts." (all from the same source). + +Put the two sources together for the Twisting Nether case. Baron and Loot Hoarder are destroyed together. The Death Creation Step removes both. The Aura Update (Other) then runs with no Baron in play, so by the time the Death Phase resolves the Hoarder's Deathrattle, there is no doubling to apply. + +Two more cards appear in this module ([Novice Engineer](https://hearthstone.wiki.gg/wiki/Novice_Engineer), [Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)): + +- Novice Engineer, 2-mana 1/1: "Battlecry: Draw a card." +- Brann Bronzebeard, 3-mana 2/4: "Your Battlecries trigger twice." Brann is the Expansion Drop. + +In this course a Battlecry resolves when its minion is played from hand, and not when an effect summons it. + +## Predict + +Write your answers down before you open the discussion, for example in `notes/04.md` in your workspace. + +1. You played Baron Rivendare, then Loot Hoarder, then Harvest Golem, and the Golem has taken 2 damage. You cast Whirlwind. List the `Triggered` events in order. Is it Hoarder, Hoarder, Golem, Golem, or Hoarder, Golem, Hoarder, Golem? Where do the two Damaged Golems end up? +2. Two Barons and a Loot Hoarder. The Hoarder dies. How many cards do you draw? Your Module 0 engine may have given a different answer: why? +3. You played Loot Hoarder first, then Baron. You cast Twisting Nether. The Death Creation Step creates the Hoarder's Death Event first, while Baron is still on the board. How many cards do you draw? What would an engine have to do to get this wrong? +4. Where in your Module 3 engine is the one place that every trigger passes through just before it runs? What would it need to know to decide "run this twice"? Which of those facts are about a specific card? +5. A Magic: The Gathering card, Panharmonicon, makes certain triggers trigger an additional time, and two copies give *two* additional times. Baron's two copies give none extra. If both kinds of card lived in one engine, where would the difference be written down? + +
Discussion + +1. Hoarder, Hoarder, Golem, Golem. Baron survives the Whirlwind (1/7), so his aura is in force when the Death Phase starts. Each Deathrattle is queued once, and its two activations run back to back before the next entry in the Queue. The Hoarder draws twice, then the Golem summons twice. Both Damaged Golems go where the Golem stood: the first lands at that index, the second at the same index, which is now just right of the first. + +2. Two. Baron's multiplier doesn't stack. If your Module 0 fix looped over the board and ran the Deathrattle once more per Baron, you drew three. If it checked "is there a Baron", you got two. + +3. One. Both leave play in the same Death Creation Step; the Aura Update (Other) runs after the step, finds no Baron, and the Deathrattle resolves once. An engine gets this wrong if it decides "twice or once" while it creates each Death Event, or if it updates auras before the Death Creation Step. In both cases Baron is still in play at the moment of the decision, so the order of play of the two minions decides the answer, exactly as in your Module 0 engine. + +4. The Phase function (`react` in the reference): it takes each queued trigger and runs its effect. To decide how many times, it needs the trigger's kind (Deathrattle, Battlecry, ...), whose minion it is, and which "trigger N times" effects are in force. None of these name a card. "Which effects are in force" is something the cards in play *declare*, as data. + +5. In the data. Both are "trigger multiplier" auras; they differ in a stacking policy field: Baron's is `max` (the largest multiplier in force wins), Panharmonicon's is additive. The engine routine reads the field. It doesn't need to know which card it came from. + +
+ +## Spec + +From the course repository, copy the module's tests into your workspace: + +```sh +node tools/start-module.mjs 04 ~/my-engine +``` + +This module adds no new harness function, but `play` (the Module 1 Expansion Drop) is now required, with more behaviour: + +| TypeScript | Rust | +|---|---| +| `play(handId)` | `play(&mut self, hand_id: Id)` | + +`play` runs one Sequence. The minion moves from its owner's hand to the right end of the board (`Summoned{id}`, a new order of play, as in Module 1), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). A played minion is not *summoned* in the rulebook's sense: see Build. + +The five tests (full statements in `course/04-interceptors/scenarios.md`). Every test gives player 0 a deck of five `Test Filler`. + +- `test_04_baron_doubles`: Baron, Loot Hoarder, Harvest Golem at 1 Health; Whirlwind. Exact log: each Deathrattle triggers twice, back to back (Hoarder, Hoarder, Golem, Golem). Two draws, two Damaged Golems. +- `test_04_simultaneous_death_not_doubled`: Baron and Loot Hoarder; Twisting Nether. One draw. +- `test_04_two_barons_do_not_stack`: two Barons and a Loot Hoarder; `dealDamage(hoarder, 1)`. Two draws, not three or four. +- `test_04_aura_timing_order`: Loot Hoarder, *then* Baron; Twisting Nether. Exact log: `Destroyed(Hoarder)`, `Destroyed(Baron)`, `DeathPhase 1`, then a single `Triggered(Hoarder)`. +- `test_04_battlecry_on_play`: play a Novice Engineer from hand. Exact log: `Summoned`, `Triggered(battlecry)`, `Drew`. + +The timing test is the one that pins down *when* the multiplier is read: + +```rust +#[test] +fn test_04_aura_timing_order() { + let mut g = game_with_deck(); + let h = g.summon("Loot Hoarder", 0); + let b = g.summon("Baron Rivendare", 0); + g.clear_log(); + + g.cast("Twisting Nether", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + assert_eq!( + g.log(), + vec![ + spell_cast("Twisting Nether"), + GameEvent::Destroyed { id: h }, + GameEvent::Destroyed { id: b }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c }, + ] + ); +} +``` + +```ts +test("test_04_aura_timing_order", () => { + const g = newGame({ decks: [fillers(5), []] }); + const h = g.summon("Loot Hoarder", 0); + const b = g.summon("Baron Rivendare", 0); + g.clearLog(); + + g.cast("Twisting Nether", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Twisting Nether" }, + { type: "Destroyed", id: h }, + { type: "Destroyed", id: b }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c }, + ]); +}); +``` + +`game_with_deck`, `spell_cast` and `triggered` are small helpers at the top of the Rust test file. + +Run `cargo test` or `npm test`. The Module 4 tests fail (Baron Rivendare and Novice Engineer don't exist yet); Modules 1–3 still pass. That is RED. + +## Build + +Make the five tests pass without breaking Modules 1–3. The rules for where code goes are stricter than before, because this is the module the course is built around: + +- **Baron Rivendare is data in `src/content/`.** His card definition declares an aura: "trigger multiplier: `deathrattle`, 2 times, stacking `max`". The course's card schema already has this shape: `{kind: "aura", modifier: {type: "triggerMultiplier", trigger, times, stacking: "max" | "additive"}}` (`course/schema/card.schema.json`). Module 5 loads cards from that format; for now, write the same fields in your language. +- **One generic routine in `src/engine/` reads multipliers**, keyed by trigger kind and owner. It must not mention Baron, Deathrattle-specific doubling, or any card. If your engine contains `if deathrattle && baronInPlay`, you've rebuilt Module 0. +- **The Battlecry trigger, the `play` action and the Aura Update are rules machinery** in `src/engine/`. Novice Engineer is a card in `src/content/`. +- `src/harness` stays thin. `play` hands one action to the engine. + +
Hint 1 — nudge + +Find the loop in your Phase function that takes a queued trigger and runs its effect. Every Deathrattle, every damage trigger and (soon) every Battlecry goes through those few lines. What if they ran the effect `n` times, and something else decided `n`? + +Then: *when* is `n` decided? Look at the end-of-outermost-Phase seam you left in Module 3. + +
+ +
Hint 2 — concept + +- **Aura as data.** A card definition gets a list of auras. A trigger multiplier has `trigger` (which kind of trigger), `times` and `stacking`. It applies to triggers of minions with the same owner as the aura's minion ("Your minions"). +- **Snapshot, don't look.** Keep a list of the auras in force (each with the player whose minion has it) in your world. Recompute it in an *Aura Update (Other)*: right after each Death Creation Step, and whenever a minion is summoned. Never recompute it mid-Phase for any other reason. A minion that dies keeps its aura in the snapshot until the next update, and the next update runs straight after the step that removed it. +- **One routine.** `activations(owner, triggerKind)`: start from 1; for each multiplier in the snapshot that matches the owner and kind, `max` multipliers raise the base to their `times`, `additive` ones add `times - 1` extra. Return the result. +- **Apply it once per queue entry.** When an entry starts to resolve, ask `activations` once, then run the effect that many times, recording `Triggered` for each, back to back. +- **Battlecry:** `play` moves the minion from hand to board, then raises a "played" event that only the minion's own Battlecry answers, through the same Phase function as everything else. Don't call the Battlecry effect directly from `play`, or no multiplier will ever see it. + +
+ +
Hint 3 — pseudo-code + +```text +endOfOutermostPhase(): + // Aura Update (Health/Attack) would go first; the course has no such auras + deaths = deathCreationStep() + auraUpdateOther() // after the step: the dead no longer count + return deaths + +auraUpdateOther(): + world.activeAuras = [(owner(m), a) for m in minionsInPlay for a in card(m).auras] + +summon(card, player, at): // the Summon action and the harness both use this + id = create card on player's board at `at` + auraUpdateOther() + return id + +activations(owner, on): + max = 1; extra = 0 + for (o, a) in world.activeAuras where o == owner and a.trigger == on: + if a.stacking == max: max = maximum(max, a.times) + else: extra += a.times - 1 + return max + extra + +perform(Play(id)): // no aura update: playing is not summoning + require zone(id) == hand + move id to the right end of its owner's board // logs Summoned + react([Played(id)]) + +react(events): + ... fill and lock the Queue as in Module 3 ... + for (m, trig, e) in queue: + n = activations(owner(m), trig.on) // read once, as the entry starts + repeat n times: + log Triggered(m, label(trig.on)) + for a in trig.effect(ctx(...)): perform(a) + +responds(battlecry, m, Played(id)) -> id == m +``` + +
+ +When your tests pass, compare with the reference engine in `course/04-interceptors/reference/rust/src/engine/` or `course/04-interceptors/reference/ts/src/engine/`. The routine and the update are in `aura.rs` / `aura.ts`; the loop that applies them is `react` in `queue.rs` / `queue.ts`. + +Decisions in the reference that the tests don't force, but the rules suggest: + +- **The count is read once per queue entry, as it starts.** The rulebook says repeat-trigger cards "check before an Event starts", and adds: "if one Battlecry/Deathrattle/End of turn effect summons them, it will not trigger again. 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)). Reading the count before the first activation, and updating the snapshot on summon, gives exactly that: an effect that summons a Baron has already decided its own count, and the next queue entry sees him. No test checks this, because no card in the course summons a multiplier. +- **Playing is not summoning.** The rulebook's update point is "whenever a minion is summoned (but not played)" (same source). So `play` doesn't run an Aura Update; the played minion's auras join at the end of the outermost Phase, which is still before anything else can happen. +- **The `additive` branch is there, and no Hearthstone card in the course uses it.** It is two lines, and it is what makes `stacking` a real choice rather than a constant: see Name it. + +> **How the real game differs** +> - The real game runs an Aura Update (Health/Attack) before the Death Creation Step ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The course has no Health or Attack auras, so the reference leaves that step out. +> - The Baron Rivendare page reads the doubling at resolution of a queue entry, while the rulebook says multipliers "check before an Event starts". Both rule out a multiplier that appears mid-Event; neither says more precisely when the check happens ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare), [Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). The reference reads it when each queue entry starts. +> - "Baron Rivendare's effect also works on remotely triggered Deathrattles such as from Play Dead" ([Baron Rivendare](https://hearthstone.wiki.gg/wiki/Baron_Rivendare)). The course has no card that triggers another minion's Deathrattle. +> - The course has no mana, so `play` costs nothing, and a Battlecry here never chooses a target. + +## Name it + +The pattern is an **interceptor**, also called a modifier pipeline. A step of the engine (here, "run this trigger's effect") consults a list of modifiers before it acts. The modifiers are data contributed by whatever is in play. The step knows the *kinds* of modifier, never the cards that supply them. Adding a card that modifies the step adds data, not a branch. + +Here is the whole interceptor in the reference, the routine and the one place that calls it: + +```rust +pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 { + let mut max = 1; + let mut extra = 0; + for active in world.active_auras() { + let Aura::TriggerMultiplier { + trigger, + times, + stacking, + } = active.aura; + if active.owner != owner || trigger != on { + continue; + } + match stacking { + Stacking::Max => max = max.max(times), + Stacking::Additive => extra += times - 1, + } + } + max + extra +} + +// in react, for each queued (source, trigger, event): +let times = activations(world, world.owner_of(source), trigger.on); +for _ in 0..times { + world.record(Event::Triggered { source, on: trigger.on }); + // ... build the context, run the effect, perform its actions ... +} +``` + +```ts +export function activations(world: World, owner: PlayerId, on: TriggerOn): number { + let max = 1; + let extra = 0; + for (const active of world.activeAuras()) { + const { trigger, times, stacking } = active.aura; + if (active.owner !== owner || trigger !== on) continue; + if (stacking === "max") max = Math.max(max, times); + else extra += times - 1; + } + return max + extra; +} + +// in react, for each queued [source, trigger, event]: +const times = activations(world, world.ownerOf(source), trigger.on); +for (let i = 0; i < times; i++) { + world.record({ type: "Triggered", source, on: trigger.on }); + // ... build the context, run the effect, perform its actions ... +} +``` + +And Baron Rivendare, the card that took a Deathrattle rewrite in Module 0: + +```rust +// "Your minions trigger their Deathrattles twice." Two Barons still only double. +CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier { + trigger: TriggerOn::Deathrattle, + times: 2, + stacking: Stacking::Max, +}), +``` + +```ts +// "Your minions trigger their Deathrattles twice." Two Barons still only double. +minion("Baron Rivendare", 1, 7, [], [ + { type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" }, +]), +``` + +**Before.** Fireplace, the Python Hearthstone simulator from [Module 0](../00-rewrite-trap/lesson.md), puts the doubling inside the Deathrattle action: `class Deathrattle` in [`fireplace/actions.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/actions.py) (commit `47a2572`) has a branch on `target.controller.extra_deathrattles` that queues the actions again. Read it. The flag is a boolean set by Baron's aura, so two Barons can't stack there either. In that shape, Brann would need a flag of his own and a branch in whatever code runs Battlecries: each "trigger N times" card is an edit to the code that runs that trigger. + +**After.** Magic: The Gathering has the rule in general form. The [Comprehensive Rules](https://magic.wizards.com/en/rules), 603.2c: "An ability triggers only once each time its trigger event occurs." 603.2d: "An ability may state that a triggered ability triggers additional times." … "then that ability triggers that many times." A multiplier is a modifier on how often a trigger fires, not a change to the ability itself. + +The Argentum engine (an MTG rules engine in Kotlin) implements it the way this module does. The card [`Panharmonicon.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/mtg-sets/2008-2016/src/main/kotlin/com/wingedsheep/mtg/sets/definitions/kld/cards/Panharmonicon.kt) declares a static ability `AdditionalETBOrLTBTriggers(...)`: pure data, with a filter for which objects' enter/leave triggers it applies to. One generic routine, `duplicateETBOrLTBTriggers` in [`TriggerDetector.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/rules-engine/src/main/kotlin/com/wingedsheep/engine/event/TriggerDetector.kt), consumes every such ability. Its doc comment states the stacking policy: "Multiple copies are additive: N copies add N extra copies of each trigger." Baron's page says the opposite for Baron. Same mechanism, different policy, which is why `stacking` is a field of the modifier and not a rule of the engine. + +Argentum's Panharmonicon file also carries a ruling: "Replacement effects are unaffected by Panharmonicon's ability". That is the line between two kinds of interceptor. A trigger multiplier changes how many times a trigger fires. A **replacement effect** rewrites an event before it happens: Comprehensive Rules 614.1a, "Effects that use the word 'instead' are replacement effects." 614.5 adds the guard every rewrite system needs: "A replacement effect doesn't invoke itself repeatedly; it gets only one opportunity to affect an event". And when many modifiers touch the same values, rule 613 orders them in layers: "all applicable continuous effects are applied in a series of layers in the following order". The course needs only the first kind; the other two are the same idea (data consulted by one step, with an explicit ordering) at a larger scale. + +**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "aura", "trigger multiplier", "Battlecry" and "play". It must not know "Baron Rivendare". + +## Expansion Drop + +A new card arrives: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). This is the Module 0 question: in your naive engine, Brann meant another edit to the code that runs triggers. + +The constraint, as always: **do not edit anything under `src/engine/`**. The harness doesn't need to change either. Only `src/content/` should. + +1. Commit your workspace and note the commit: `git add -A && git commit -m "module 4 done"`. +2. Copy the expansion test for your track from `course/04-interceptors/expansion/tests/rust/test_04x.rs` or `course/04-interceptors/expansion/tests/ts/test_04x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Add Brann Bronzebeard to your content. +4. When the tests pass, run the check from the course repository: + +```sh +node tools/expansion-check.mjs ~/my-engine HEAD +``` + +The test, `test_04x_brann_doubles_battlecry`, summons Brann, then plays a Novice Engineer from hand. Its Battlecry is queued once and activates twice, back to back: two `Triggered(battlecry)` events, two draws. The full scenario is in `course/04-interceptors/expansion/README.md`. + +
Discussion: what this drop measures + +If your multiplier is keyed by trigger kind and your Battlecry goes through the Phase function, Brann is one entry in your content library: + +```rust +// Expansion Drop: "Your Battlecries trigger twice." +CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier { + trigger: TriggerOn::Battlecry, + times: 2, + stacking: Stacking::Max, +}), +``` + +```ts +// Expansion Drop: "Your Battlecries trigger twice." +minion("Brann Bronzebeard", 2, 4, [], [ + { type: "triggerMultiplier", trigger: "battlecry", times: 2, stacking: "max" }, +]), +``` + +That is the reference answer in `course/04-interceptors/expansion/solution/`. It is Baron with one word changed. The course's sources don't say how two Branns combine, and the test uses one, so the reference simply reuses Baron's `max`. + +Shapes that fail this drop: + +- **A doubling flag.** If Baron sets something like `extraDeathrattles` on the player (Fireplace's shape), Brann needs a second flag, and the engine has to read it somewhere. +- **The multiplier lives in the Death Phase.** If only the code that resolves Death Events asks "how many times?", Battlecries never ask. The question belongs in the one loop every trigger passes through. +- **`play` calls the Battlecry directly.** If `play` runs the effect itself instead of raising an event for the Phase function, no multiplier, and no other rule of the queue, ever sees a Battlecry. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Further reading + +- [Baron Rivendare, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Baron_Rivendare): the notes section, on queuing, stacking and simultaneous death. +- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): the aura rules (Rule 4 and its notes): the two kinds of Aura Update, when each runs, and the repeat-trigger cards that "check before an Event starts". +- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules): 603.2c–d (triggering additional times), 614 (replacement effects, especially 614.1 and 614.5) and 613.1 (layers). Read 603.2d first: it is this module in one sentence. +- Argentum, [`Panharmonicon.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/mtg-sets/2008-2016/src/main/kotlin/com/wingedsheep/mtg/sets/definitions/kld/cards/Panharmonicon.kt) and `duplicateETBOrLTBTriggers` in [`TriggerDetector.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/rules-engine/src/main/kotlin/com/wingedsheep/engine/event/TriggerDetector.kt) (the file is long; search for the function name). Compare the card file with your Baron entry, and the function with your `activations`. +- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §2.3 "Rule 613: Base State vs. Projected State" and §2.7 on replacement effects as data. The same pattern, grown to a full rules engine. +- Fireplace, `class Deathrattle` in [`fireplace/actions.py`](https://github.com/jleclanche/fireplace/blob/47a2572/fireplace/actions.py) at commit `47a2572`: the "before" picture. Ask what the next "trigger twice" card would cost there. diff --git a/course/04-interceptors/reference/rust/.course.json b/course/04-interceptors/reference/rust/.course.json new file mode 100644 index 0000000..4a59b17 --- /dev/null +++ b/course/04-interceptors/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "04" } diff --git a/course/04-interceptors/reference/rust/.gitignore b/course/04-interceptors/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/04-interceptors/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/04-interceptors/reference/rust/Cargo.toml b/course/04-interceptors/reference/rust/Cargo.toml new file mode 100644 index 0000000..e992026 --- /dev/null +++ b/course/04-interceptors/reference/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "engine" +version = "0.1.0" +edition = "2021" + +[dependencies] +# serde_json arrives at Module 5; nothing needed yet. diff --git a/course/04-interceptors/reference/rust/README.md b/course/04-interceptors/reference/rust/README.md new file mode 100644 index 0000000..d5dbb73 --- /dev/null +++ b/course/04-interceptors/reference/rust/README.md @@ -0,0 +1,15 @@ +# Module 4 reference engine (Rust) + +The course's own engine as of the end of Module 4. It adds auras as card data and one routine that decides how many times a queued trigger activates, so "Your minions trigger their Deathrattles twice." is a card, not a change to Deathrattle. It also adds playing a minion from hand, and Battlecries. Module 3's reference was the starting point, so a diff between the two shows what this module adds. Module 5's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `aura.rs`: the Aura Update (Other), which snapshots the auras of every minion in play, and `activations`, the one routine that reads the snapshot and returns how many times a trigger of a given kind activates for a given player. Stacking policy (`Max` or `Additive`) is a field of each multiplier. + - `queue.rs`: actions, the context an effect sees, and the resolver. The end of the outermost Phase runs the Death Creation Step, then the Aura Update (Other). Summoning a minion also runs the Aura Update; playing one does not. Each queued trigger asks `activations` once, as it starts, and then activates that many times back to back. A `Play` action moves a minion from hand to board and raises a `Played` event, which the minion's Battlecry answers. + - `death.rs`: the Death Creation Step, unchanged from Module 3. + - `world.rs`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag, the aura snapshot and the log. + - `card.rs`: card definitions (stats, triggers, auras, a spell's effect) and the library. + - `event.rs`: the engine's events. `Played` is engine-only: `src/harness.rs` leaves it out of the course's `GameEvent` log, where the played minion's `Summoned` already shows it. +- `src/content/mod.rs`: card definitions. Baron Rivendare is one aura entry: `Aura::TriggerMultiplier` on `TriggerOn::Deathrattle`, `times` 2, `Stacking::Max`. Twisting Nether (the Module 3 Expansion Drop) is here because the Module 4 tests use it. +- `src/harness.rs`: the adapter the course tests call. `summon` goes through the engine's summon, so it runs an Aura Update. `play` (the Module 1 Expansion Drop) runs one Sequence with a `Play` action. + +Run it with the module's tests: `node tools/verify-references.mjs 04` from the course repository. diff --git a/course/04-interceptors/reference/rust/src/content/mod.rs b/course/04-interceptors/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..e9461fa --- /dev/null +++ b/course/04-interceptors/reference/rust/src/content/mod.rs @@ -0,0 +1,84 @@ +//! Content: card definitions. Only this directory may name specific cards. +//! +//! Each effect is a small Rust function that returns engine actions. Module 5 +//! replaces these functions with card data the engine interprets. Auras are +//! already data: the engine reads them, and no engine code knows these cards. + +use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, TriggerOn}; + +/// Every card this game knows. +pub fn library() -> CardLibrary { + CardLibrary::new(vec![ + // Fictional test minion with no text. + CardDef::minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + CardDef::minion("Armorsmith", 1, 4).on(TriggerOn::FriendlyMinionDamaged, |c| { + vec![Action::GainArmor { + player: c.owner, + amount: 1, + }] + }), + // "Whenever this minion takes damage, draw a card." + CardDef::minion("Acolyte of Pain", 1, 4).on(TriggerOn::SelfDamaged, |c| { + vec![Action::Draw { player: c.owner }] + }), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + CardDef::minion("Test Echo", 1, 5).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::Damage { + targets: vec![c.enemy_hero()], + amount: 1, + }] + }), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + CardDef::minion("Test Reactor", 1, 5).on(TriggerOn::HeroDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] + }), + // "Deathrattle: Draw a card." + CardDef::minion("Loot Hoarder", 2, 1).on(TriggerOn::Deathrattle, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deathrattle: Summon a 2/1 Damaged Golem." + CardDef::minion("Harvest Golem", 2, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Summon { + card: "Damaged Golem".to_string(), + player: c.owner, + at: c.position, + }] + }), + // Summoned by Harvest Golem; no text. + CardDef::minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + CardDef::minion("Unstable Ghoul", 1, 3).on(TriggerOn::Deathrattle, |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Your minions trigger their Deathrattles twice." Two Barons still only double. + CardDef::minion("Baron Rivendare", 1, 7).aura(Aura::TriggerMultiplier { + trigger: TriggerOn::Deathrattle, + times: 2, + stacking: Stacking::Max, + }), + // "Battlecry: Draw a card." + CardDef::minion("Novice Engineer", 1, 1).on(TriggerOn::Battlecry, |c| { + vec![Action::Draw { player: c.owner }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + // "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.) + CardDef::spell("Twisting Nether", |c| { + vec![Action::Destroy { + targets: c.world.minions_in_play(), + }] + }), + ]) +} diff --git a/course/04-interceptors/reference/rust/src/engine/aura.rs b/course/04-interceptors/reference/rust/src/engine/aura.rs new file mode 100644 index 0000000..0656db4 --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/aura.rs @@ -0,0 +1,53 @@ +//! Auras, and the one routine that applies trigger multipliers. +//! +//! An aura is data on a card: a continuous effect its minion has while in play. +//! The engine never asks "is that card on the board?" in the middle of resolving +//! something. It takes a snapshot of the auras in force at defined points (an Aura +//! Update) and every rule reads the snapshot. +//! +//! Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for +//! Health/Attack auras before the Death Creation Step, and one for every other aura +//! after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death +//! Creation Step no longer multiplies the Deathrattles of that step's Death Phase. +//! Summoning a minion also runs an Aura Update. + +use super::card::{Aura, Stacking, TriggerOn}; +use super::world::{ActiveAura, PlayerId, World}; + +/// Aura Update (Other): replace the snapshot with the auras of every minion now in play. +pub fn aura_update_other(world: &mut World) { + let mut active = Vec::new(); + for id in world.minions_in_play() { + for &aura in &world.card(world.card_name(id)).auras { + active.push(ActiveAura { + owner: world.owner_of(id), + aura, + }); + } + } + world.set_active_auras(active); +} + +/// How many times a trigger listening for `on`, on a minion `owner` controls, activates +/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in +/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest +/// `times`; each `Stacking::Additive` one adds `times - 1` extra activations. +pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 { + let mut max = 1; + let mut extra = 0; + for active in world.active_auras() { + let Aura::TriggerMultiplier { + trigger, + times, + stacking, + } = active.aura; + if active.owner != owner || trigger != on { + continue; + } + match stacking { + Stacking::Max => max = max.max(times), + Stacking::Additive => extra += times - 1, + } + } + max + extra +} diff --git a/course/04-interceptors/reference/rust/src/engine/card.rs b/course/04-interceptors/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..e2f3dac --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/card.rs @@ -0,0 +1,133 @@ +//! Card definitions as the engine sees them. The definitions themselves live in `content`. + +use std::collections::HashMap; + +use super::queue::{Action, Ctx}; + +/// A card's behaviour: given where and for whom it runs, the actions to perform. +/// Module 2 writes these as Rust functions in `content`; Module 5 turns them into data. +pub type EffectFn = fn(&Ctx) -> Vec; + +/// What a triggered ability listens for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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, +} + +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", + } + } +} + +/// How several trigger multipliers for the same trigger combine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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)] +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, + }, +} + +#[derive(Debug, Clone, Copy)] +pub struct Trigger { + pub on: TriggerOn, + pub effect: EffectFn, +} + +/// What the engine needs to know about a card. +#[derive(Debug, Clone)] +pub struct CardDef { + pub name: String, + pub attack: i32, + pub health: i32, + /// Triggered abilities, in printed order. + pub triggers: Vec, + /// Auras this minion has while in play. + pub auras: Vec, + /// A spell's effect when cast; `None` for minions. + pub on_cast: Option, +} + +impl CardDef { + pub fn minion(name: &str, attack: i32, health: i32) -> CardDef { + CardDef { + name: name.to_string(), + attack, + health, + triggers: Vec::new(), + auras: Vec::new(), + on_cast: None, + } + } + + pub fn spell(name: &str, effect: EffectFn) -> CardDef { + CardDef { + on_cast: Some(effect), + ..CardDef::minion(name, 0, 0) + } + } + + /// Add a triggered ability: "Whenever , ". + pub fn on(mut self, on: TriggerOn, effect: EffectFn) -> CardDef { + self.triggers.push(Trigger { on, effect }); + self + } + + /// Add an aura. + pub fn aura(mut self, aura: Aura) -> CardDef { + self.auras.push(aura); + self + } +} + +/// 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 cards = defs.into_iter().map(|d| (d.name.clone(), d)).collect(); + CardLibrary { cards } + } + + pub fn get(&self, name: &str) -> &CardDef { + self.cards + .get(name) + .unwrap_or_else(|| panic!("unknown card {name:?}: define it in src/content")) + } +} diff --git a/course/04-interceptors/reference/rust/src/engine/death.rs b/course/04-interceptors/reference/rust/src/engine/death.rs new file mode 100644 index 0000000..69a00e3 --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/death.rs @@ -0,0 +1,30 @@ +//! The Death Creation Step: the one place minions leave play. +//! +//! Damage and destroy effects only mark a minion (0 or less Health, or pending +//! destroy). It stays in play, and keeps triggering, until the outermost Phase +//! ends. Then this step removes every marked minion at once. + +use super::event::Event; +use super::world::World; + +/// Remove every mortally wounded or pending-destroy minion from play, simultaneously. +/// Returns their Death Events in order of play; empty if nothing died. +pub fn death_creation_step(world: &mut World) -> Vec { + // Every position is read before anything moves: removal is simultaneous, and each + // Death Event remembers where its minion stood when the step began. + let mut deaths = Vec::new(); + for id in world.minions_in_play() { + if world.is_doomed(id) { + let position = world + .board_index(id) + .expect("a minion in play is on the board"); + deaths.push(Event::Destroyed { id, position }); + } + } + for death in &deaths { + if let Event::Destroyed { id, position } = *death { + world.kill(id, position); + } + } + deaths +} diff --git a/course/04-interceptors/reference/rust/src/engine/event.rs b/course/04-interceptors/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..5d4f33e --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/event.rs @@ -0,0 +1,51 @@ +//! What happened, in order. The engine records an event each time it changes the game. + +use super::card::TriggerOn; +use super::world::{EntityId, PlayerId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Summoned { + id: EntityId, + }, + Damaged { + target: EntityId, + amount: i32, + }, + Triggered { + source: EntityId, + on: TriggerOn, + }, + Drew { + player: PlayerId, + id: EntityId, + }, + Fatigue { + player: PlayerId, + }, + ArmorGained { + player: PlayerId, + amount: i32, + }, + StatsChanged { + id: EntityId, + attack: i32, + health: i32, + }, + SpellCast { + card: String, + }, + /// A Death Event: the Death Creation Step moved `id` from play to the graveyard. + /// `position` is the board index it had when the step began; its Deathrattle summons there. + Destroyed { + id: EntityId, + position: usize, + }, + DeathPhase { + round: u32, + }, + /// A minion was played from hand (it has just entered the board). Its Battlecry answers this. + Played { + id: EntityId, + }, +} diff --git a/course/04-interceptors/reference/rust/src/engine/mod.rs b/course/04-interceptors/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..02cd843 --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/mod.rs @@ -0,0 +1,28 @@ +//! 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. + +mod aura; +mod card; +mod death; +mod event; +mod queue; +mod world; + +pub use card::{Aura, CardDef, CardLibrary, EffectFn, Stacking, Trigger, TriggerOn}; +pub use event::Event; +pub use queue::{run_sequence, summon, Action, Ctx}; +pub use world::{EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/04-interceptors/reference/rust/src/engine/queue.rs b/course/04-interceptors/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..776db5e --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/queue.rs @@ -0,0 +1,259 @@ +//! 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. + +use super::aura::{activations, aura_update_other}; +use super::card::TriggerOn; +use super::death::death_creation_step; +use super::event::Event; +use super::world::{EntityId, PlayerId, World, Zone}; + +/// Something the engine can do. Card effects return these instead of changing the world. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Deal `amount` to each target at once: all damage first, then the reactions. + Damage { + targets: Vec, + amount: i32, + }, + Draw { + player: PlayerId, + }, + GainArmor { + player: PlayerId, + amount: i32, + }, + GainAttack { + id: EntityId, + amount: i32, + }, + Cast { + card: String, + player: PlayerId, + target: Option, + }, + /// Mark each target pending destroy. They leave play in the next Death Creation Step. + Destroy { + targets: Vec, + }, + /// Summon `card` for `player` at board index `at`, or at the right end if `at` is `None`. + Summon { + card: String, + player: PlayerId, + at: Option, + }, + /// Play a minion from its owner's hand: it enters the right end of the board, then its + /// Battlecry resolves. + Play { + id: EntityId, + }, +} + +/// What an effect may look at when it runs. +pub struct Ctx<'a> { + pub world: &'a World, + /// The player the effect works for. + pub owner: PlayerId, + /// The minion whose trigger is resolving; `None` for a spell. + pub source: Option, + /// The chosen target, if any. + pub target: Option, + /// The source's board position. For a Deathrattle, the source has left play, + /// and this is the position its Death Event remembered. + pub position: Option, +} + +impl Ctx<'_> { + /// The minion whose trigger is resolving. + pub fn minion(&self) -> EntityId { + self.source.expect("only a triggered ability has a source") + } + + pub fn enemy_hero(&self) -> EntityId { + self.world.hero(1 - self.owner) + } +} + +/// Run one Sequence: a player action and everything it causes. +pub fn run_sequence(world: &mut World, action: Action) { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let mut deaths = end_of_outermost_phase(world); + let mut round = 0; + while !deaths.is_empty() { + round += 1; + world.record(Event::DeathPhase { round }); + react(world, &deaths); + deaths = end_of_outermost_phase(world); + } +} + +/// The steps that run only when the outermost Phase ends; returns the Death Events. +/// Hearthstone runs an Aura Update (Health/Attack) first. The course has no +/// Health/Attack auras, so that step has nothing to do and is left out. +fn end_of_outermost_phase(world: &mut World) -> Vec { + let deaths = death_creation_step(world); + // After the step: a minion that just died no longer contributes its auras + // to the Death Phase that resolves these deaths. + aura_update_other(world); + deaths +} + +/// Summon `card` for `player` at board index `at` (or the right end). Summoning runs an +/// Aura Update. +pub fn summon(world: &mut World, card: &str, player: PlayerId, at: Option) -> EntityId { + let id = world.create_at(card, player, Zone::Board, at); + aura_update_other(world); + id +} + +fn perform(world: &mut World, action: Action) { + match action { + Action::Damage { + mut targets, + amount, + } => { + // Area damage: every Damage Event is created, in order of play, before any reaction. + targets.sort_by_key(|&id| world.order_of_play(id)); + let mut events = Vec::new(); + for target in targets { + world.damage(target, amount); + events.push(Event::Damaged { target, amount }); + } + react(world, &events); + } + Action::Draw { player } => world.draw(player), + Action::GainArmor { player, amount } => world.gain_armor(player, amount), + Action::GainAttack { id, amount } => world.gain_attack(id, amount), + Action::Destroy { targets } => { + for target in targets { + world.mark_destroyed(target); + } + } + Action::Summon { card, player, at } => { + summon(world, &card, player, at); + } + Action::Play { id } => { + assert!( + world.zone_of(id) == Zone::Hand, + "play: entity {id} is not in a hand" + ); + // Playing is not summoning: no Aura Update here. The played minion's own + // auras join at the end of the outermost Phase. + world.move_to(id, Zone::Board); + let played = Event::Played { id }; + world.record(played.clone()); + react(world, &[played]); + } + Action::Cast { + card, + player, + target, + } => { + let effect = world + .card(&card) + .on_cast + .unwrap_or_else(|| panic!("{card:?} is not a spell")); + world.record(Event::SpellCast { card }); + let actions = effect(&Ctx { + world, + owner: player, + source: None, + target, + position: None, + }); + for action in actions { + perform(world, action); + } + } + } +} + +/// A Phase: queue every trigger that responds to `events`, then resolve them one by one. +fn react(world: &mut World, events: &[Event]) { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + let mut queue = Vec::new(); + for event in events { + for listener in listeners(world, event) { + for trigger in &world.card(world.card_name(listener)).triggers { + if responds(world, trigger.on, listener, event) { + queue.push((listener, *trigger, event)); + } + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (source, trigger, event) in queue { + // The interceptor: the entry was queued once; the auras in force when it + // starts decide how many times it activates, back to back. + let times = activations(world, world.owner_of(source), trigger.on); + for _ in 0..times { + world.record(Event::Triggered { + source, + on: trigger.on, + }); + let position = match *event { + Event::Destroyed { id, position } if id == source => Some(position), + _ => world.board_index(source), + }; + let ctx = Ctx { + world, + owner: world.owner_of(source), + source: Some(source), + target: None, + position, + }; + let actions = (trigger.effect)(&ctx); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for action in actions { + perform(world, action); + } + } + } +} + +/// Who may respond to `event`, in order of play: every minion in play and, for a +/// Death Event, the minion that died. It has left play, but its Deathrattle still answers. +fn listeners(world: &World, event: &Event) -> Vec { + let mut ids = world.minions_in_play(); + if let Event::Destroyed { id, .. } = *event { + ids.push(id); + ids.sort_by_key(|&id| world.order_of_play(id)); + } + ids +} + +/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`? +fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool { + if let Event::Destroyed { id, .. } = *event { + return on == TriggerOn::Deathrattle && id == listener; + } + if let Event::Played { id } = *event { + return on == TriggerOn::Battlecry && id == listener; + } + let Event::Damaged { target, .. } = *event else { + return false; + }; + let is_minion = !world.is_hero(target); + match on { + TriggerOn::SelfDamaged => target == listener, + TriggerOn::FriendlyMinionDamaged => { + is_minion && world.owner_of(target) == world.owner_of(listener) + } + TriggerOn::AnyMinionDamaged => is_minion, + TriggerOn::HeroDamaged => !is_minion, + TriggerOn::Deathrattle | TriggerOn::Battlecry => false, + } +} diff --git a/course/04-interceptors/reference/rust/src/engine/world.rs b/course/04-interceptors/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..87bd2e5 --- /dev/null +++ b/course/04-interceptors/reference/rust/src/engine/world.rs @@ -0,0 +1,315 @@ +//! 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, CardDef, CardLibrary}; +use super::event::Event; + +/// A handle to an entity. Ids are never reused and stay valid when the entity changes zone. +pub type EntityId = u32; +pub type PlayerId = u8; + +/// Starting Health of each hero. +pub const HERO_HEALTH: i32 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Zone { + Deck, + Hand, + Board, + Graveyard, +} + +#[derive(Debug)] +struct Entity { + card: String, + owner: PlayerId, + zone: Zone, + /// Stamped each time the entity enters the board; `None` until it first does. + order_of_play: Option, + attack: i32, + /// Current Health. At 0 or below the entity is mortally wounded: still in play until the + /// Death Creation Step. + health: i32, + /// Hit by a destroy effect: still in play until the Death Creation Step. + pending_destroy: bool, +} + +/// An aura in force: which player's minion has it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActiveAura { + pub owner: PlayerId, + pub aura: Aura, +} + +/// One player's zones. Each is an ordered list of ids (the board is left to right). +#[derive(Debug, Default)] +struct Side { + deck: Vec, + hand: Vec, + board: Vec, + graveyard: Vec, + hero: EntityId, + armor: i32, +} + +impl Side { + fn zone(&self, zone: Zone) -> &Vec { + match zone { + Zone::Deck => &self.deck, + Zone::Hand => &self.hand, + Zone::Board => &self.board, + Zone::Graveyard => &self.graveyard, + } + } + + fn zone_mut(&mut self, zone: Zone) -> &mut Vec { + match zone { + Zone::Deck => &mut self.deck, + Zone::Hand => &mut self.hand, + Zone::Board => &mut self.board, + Zone::Graveyard => &mut self.graveyard, + } + } +} + +pub struct World { + library: CardLibrary, + /// Indexed by `EntityId`. + entities: Vec, + sides: [Side; 2], + next_order_of_play: u32, + log: Vec, + /// The auras in force as of the last Aura Update. Changes only there (see `aura.rs`). + auras: Vec, +} + +impl World { + /// A new game: both heroes are in play from the start, so they are the oldest entities. + pub fn new(library: CardLibrary) -> World { + let mut world = World { + library, + entities: Vec::new(), + sides: Default::default(), + next_order_of_play: 0, + log: Vec::new(), + auras: Vec::new(), + }; + for owner in 0..2 { + let id = world.entities.len() as EntityId; + world.next_order_of_play += 1; + world.entities.push(Entity { + card: "Hero".to_string(), + owner, + zone: Zone::Board, + order_of_play: Some(world.next_order_of_play), + attack: 0, + health: HERO_HEALTH, + pending_destroy: false, + }); + // In play, but not on the board list: the board holds minions only. + world.sides[owner as usize].hero = id; + } + world + } + + /// Create an entity of `card` for `owner` at the end of `zone` (the right end for the board). + pub fn create(&mut self, card: &str, owner: PlayerId, zone: Zone) -> EntityId { + self.create_at(card, owner, zone, None) + } + + /// Create an entity of `card` for `owner` in `zone` at index `at` (clamped to the + /// zone's length), or at the end if `at` is `None`. + pub fn create_at( + &mut self, + card: &str, + owner: PlayerId, + zone: Zone, + at: Option, + ) -> EntityId { + assert!(owner < 2, "no player {owner}"); + let def = self.library.get(card); + let entity = Entity { + card: def.name.clone(), + owner, + zone, + order_of_play: None, + attack: def.attack, + health: def.health, + pending_destroy: false, + }; + let id = self.entities.len() as EntityId; + self.entities.push(entity); + self.enter(id, zone, at); + id + } + + /// Move an entity to the end of `zone` on its owner's side, keeping its id. + pub fn move_to(&mut self, id: EntityId, zone: Zone) { + let (owner, from) = (self.entity(id).owner, self.entity(id).zone); + self.sides[owner as usize] + .zone_mut(from) + .retain(|&e| e != id); + self.enter(id, zone, None); + } + + /// The single place an entity arrives in a zone. Entering the board starts a new order of play. + fn enter(&mut self, id: EntityId, zone: Zone, at: Option) { + let owner = self.entity(id).owner; + let list = self.sides[owner as usize].zone_mut(zone); + list.insert(at.map_or(list.len(), |i| i.min(list.len())), id); + let entity = &mut self.entities[id as usize]; + entity.zone = zone; + if zone == Zone::Board { + self.next_order_of_play += 1; + entity.order_of_play = Some(self.next_order_of_play); + self.record(Event::Summoned { id }); + } + } + + fn entity(&self, id: EntityId) -> &Entity { + self.entities + .get(id as usize) + .unwrap_or_else(|| panic!("unknown entity id {id}")) + } + + fn entity_mut(&mut self, id: EntityId) -> &mut Entity { + self.entities + .get_mut(id as usize) + .unwrap_or_else(|| panic!("unknown entity id {id}")) + } + + // ---- Queries ------------------------------------------------------- + + pub fn card(&self, name: &str) -> &CardDef { + self.library.get(name) + } + + 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 + } + + /// Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next + /// Death Creation Step. + pub fn is_doomed(&self, id: EntityId) -> bool { + let entity = self.entity(id); + entity.health <= 0 || entity.pending_destroy + } + + /// Index of a minion on its owner's board, left to right; `None` if it is not on the board. + pub fn board_index(&self, id: EntityId) -> Option { + self.zone(self.owner_of(id), Zone::Board) + .iter() + .position(|&e| e == id) + } + + pub fn armor(&self, owner: PlayerId) -> i32 { + self.sides[owner as usize].armor + } + + pub fn active_auras(&self) -> &[ActiveAura] { + &self.auras + } + + pub fn log(&self) -> &[Event] { + &self.log + } + + // ---- Changes: each one records its event ---------------------------- + + pub fn record(&mut self, event: Event) { + self.log.push(event); + } + + /// Only an Aura Update calls this. + pub fn set_active_auras(&mut self, auras: Vec) { + self.auras = auras; + } + + pub fn clear_log(&mut self) { + self.log.clear(); + } + + pub fn damage(&mut self, target: EntityId, amount: i32) { + self.entity_mut(target).health -= amount; + self.record(Event::Damaged { target, amount }); + } + + /// Draw the top card of `player`'s deck (the first in the list). An empty deck + /// records `Fatigue` and changes nothing else. + pub fn draw(&mut self, player: PlayerId) { + match self.zone(player, Zone::Deck).first() { + Some(&id) => { + self.move_to(id, Zone::Hand); + self.record(Event::Drew { player, id }); + } + None => self.record(Event::Fatigue { player }), + } + } + + /// Mark for destruction. Nothing leaves play until the Death Creation Step. + pub fn mark_destroyed(&mut self, id: EntityId) { + self.entity_mut(id).pending_destroy = true; + } + + /// Move a minion from play to its owner's graveyard, recording its Death Event. + pub fn kill(&mut self, id: EntityId, position: usize) { + self.entity_mut(id).pending_destroy = false; + self.move_to(id, Zone::Graveyard); + self.record(Event::Destroyed { id, position }); + } + + pub fn gain_armor(&mut self, player: PlayerId, amount: i32) { + self.sides[player as usize].armor += amount; + self.record(Event::ArmorGained { player, amount }); + } + + pub fn 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/04-interceptors/reference/rust/src/harness.rs b/course/04-interceptors/reference/rust/src/harness.rs new file mode 100644 index 0000000..4d050a2 --- /dev/null +++ b/course/04-interceptors/reference/rust/src/harness.rs @@ -0,0 +1,157 @@ +//! 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::{run_sequence, summon, Action, Event, World}; + +pub use crate::engine::Zone; + +pub type Id = u32; +pub type Player = u8; + +#[derive(Debug, Default, Clone)] +pub struct GameOpts { + pub decks: Option<[Vec; 2]>, +} + +/// The events the tests read from `log()` (see course/harness.md). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameEvent { + Summoned { id: Id }, + Damaged { target: Id, amount: i32 }, + Triggered { source: Id, trigger: String }, + Destroyed { id: Id }, + DeathPhase { round: u32 }, + Drew { player: Player, id: Id }, + Fatigue { player: Player }, + ArmorGained { player: Player, amount: i32 }, + StatsChanged { id: Id, attack: i32, health: i32 }, + SpellCast { card: String }, +} + +impl GameEvent { + /// The course's view of an engine event; `None` for engine events the course log + /// doesn't show. + fn from_engine(event: &Event) -> Option { + Some(match event.clone() { + Event::Summoned { id } => GameEvent::Summoned { id }, + Event::Damaged { target, amount } => GameEvent::Damaged { target, amount }, + Event::Triggered { source, on } => GameEvent::Triggered { + source, + trigger: on.label().to_string(), + }, + Event::Drew { player, id } => GameEvent::Drew { player, id }, + Event::Fatigue { player } => GameEvent::Fatigue { player }, + Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount }, + Event::StatsChanged { id, attack, health } => { + GameEvent::StatsChanged { id, attack, health } + } + Event::SpellCast { card } => GameEvent::SpellCast { card }, + Event::Destroyed { id, .. } => GameEvent::Destroyed { id }, + Event::DeathPhase { round } => GameEvent::DeathPhase { round }, + // A played minion's `Summoned` already shows it entering the board. + Event::Played { .. } => return None, + }) + } +} + +pub struct Game { + world: World, +} + +pub fn new_game(opts: GameOpts) -> Game { + let mut world = World::new(content::library()); + if let Some(decks) = opts.decks { + for (player, deck) in decks.iter().enumerate() { + for card in deck { + world.create(card, player as Player, Zone::Deck); + } + } + } + Game { world } +} + +impl Game { + /// Put `card` at the right end of `player`'s board. + pub fn summon(&mut self, card: &str, player: Player) -> Id { + summon(&mut self.world, card, player, None) + } + pub fn add_to_hand(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Hand) + } + pub fn add_to_deck(&mut self, card: &str, player: Player) -> Id { + self.world.create(card, player, Zone::Deck) + } + /// Play a card from hand, as one Sequence: it enters the right end of the board, then + /// its Battlecry resolves. (The Module 1 Expansion Drop; required from Module 4.) + pub fn play(&mut self, hand_id: Id) { + run_sequence(&mut self.world, Action::Play { id: hand_id }); + } + pub fn zone_of(&self, id: Id) -> Zone { + self.world.zone_of(id) + } + /// Board ids, left to right. + pub fn board(&self, player: Player) -> Vec { + self.world.zone(player, Zone::Board).to_vec() + } + pub fn hand(&self, player: Player) -> Vec { + self.world.zone(player, Zone::Hand).to_vec() + } + pub fn order_of_play(&self, id: Id) -> u32 { + self.world + .order_of_play(id) + .unwrap_or_else(|| panic!("entity {id} has not entered play")) + } + pub fn name_of(&self, id: Id) -> String { + self.world.card_name(id).to_string() + } + + // ---- Module 2 ---- + + pub fn hero(&self, player: Player) -> Id { + self.world.hero(player) + } + /// Deal damage to one character, as one Sequence. + pub fn deal_damage(&mut self, target: Id, amount: i32) { + let targets = vec![target]; + run_sequence(&mut self.world, Action::Damage { targets, amount }); + } + pub fn cast(&mut self, card: &str, player: Player, target: Option) { + let card = card.to_string(); + run_sequence( + &mut self.world, + Action::Cast { + card, + player, + target, + }, + ); + } + pub fn health(&self, id: Id) -> i32 { + self.world.health(id) + } + pub fn attack(&self, id: Id) -> i32 { + self.world.attack(id) + } + pub fn armor(&self, player: Player) -> i32 { + self.world.armor(player) + } + pub fn log(&self) -> Vec { + self.world + .log() + .iter() + .filter_map(GameEvent::from_engine) + .collect() + } + pub fn clear_log(&mut self) { + self.world.clear_log(); + } + + // ---- Module 3 ---- + + /// Mark `target` pending destroy, as one Sequence. It leaves play in the death step. + pub fn destroy(&mut self, target: Id) { + let targets = vec![target]; + run_sequence(&mut self.world, Action::Destroy { targets }); + } +} diff --git a/course/04-interceptors/reference/rust/src/lib.rs b/course/04-interceptors/reference/rust/src/lib.rs new file mode 100644 index 0000000..f5868fb --- /dev/null +++ b/course/04-interceptors/reference/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod content; +pub mod engine; +pub mod harness; diff --git a/course/04-interceptors/reference/ts/.course.json b/course/04-interceptors/reference/ts/.course.json new file mode 100644 index 0000000..f82a22e --- /dev/null +++ b/course/04-interceptors/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "04" } diff --git a/course/04-interceptors/reference/ts/.gitignore b/course/04-interceptors/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/04-interceptors/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/04-interceptors/reference/ts/README.md b/course/04-interceptors/reference/ts/README.md new file mode 100644 index 0000000..eaea987 --- /dev/null +++ b/course/04-interceptors/reference/ts/README.md @@ -0,0 +1,15 @@ +# Module 4 reference engine (TypeScript) + +The course's own engine as of the end of Module 4. It adds auras as card data and one routine that decides how many times a queued trigger activates, so "Your minions trigger their Deathrattles twice." is a card, not a change to Deathrattle. It also adds playing a minion from hand, and Battlecries. Module 3's reference was the starting point, so a diff between the two shows what this module adds. Module 5's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `aura.ts`: the Aura Update (Other), which snapshots the auras of every minion in play, and `activations`, the one routine that reads the snapshot and returns how many times a trigger of a given kind activates for a given player. Stacking policy (`max` or `additive`) is a field of each multiplier. + - `queue.ts`: actions, the context an effect sees, and the resolver. The end of the outermost Phase runs the Death Creation Step, then the Aura Update (Other). Summoning a minion also runs the Aura Update; playing one does not. Each queued trigger asks `activations` once, as it starts, and then activates that many times back to back. A `Play` action moves a minion from hand to board and raises a `Played` event, which the minion's Battlecry answers. + - `death.ts`: the Death Creation Step, unchanged from Module 3. + - `world.ts`: entities, zones, order of play, heroes, stats, Armor, the pending-destroy flag, the aura snapshot and the log. + - `card.ts`: card definitions (stats, triggers, auras, a spell's effect) and the library. + - `event.ts`: the engine's events. `Played` is engine-only: `src/harness.ts` leaves it out of the course's `GameEvent` log, where the played minion's `Summoned` already shows it. +- `src/content/index.ts`: card definitions. Baron Rivendare is one aura entry: `triggerMultiplier` on `deathrattle`, `times` 2, stacking `max`. Twisting Nether (the Module 3 Expansion Drop) is here because the Module 4 tests use it. +- `src/harness.ts`: the adapter the course tests call. `summon` goes through the engine's summon, so it runs an Aura Update. `play` (the Module 1 Expansion Drop) runs one Sequence with a `Play` action. + +Run it with the module's tests: `node tools/verify-references.mjs 04` from the course repository. diff --git a/course/04-interceptors/reference/ts/package-lock.json b/course/04-interceptors/reference/ts/package-lock.json new file mode 100644 index 0000000..8fa242d --- /dev/null +++ b/course/04-interceptors/reference/ts/package-lock.json @@ -0,0 +1,1496 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "systemic-design-workspace", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/course/04-interceptors/reference/ts/package.json b/course/04-interceptors/reference/ts/package.json new file mode 100644 index 0000000..8d2da89 --- /dev/null +++ b/course/04-interceptors/reference/ts/package.json @@ -0,0 +1,14 @@ +{ + "name": "systemic-design-workspace", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/course/04-interceptors/reference/ts/src/content/index.ts b/course/04-interceptors/reference/ts/src/content/index.ts new file mode 100644 index 0000000..be97b31 --- /dev/null +++ b/course/04-interceptors/reference/ts/src/content/index.ts @@ -0,0 +1,57 @@ +// Content: card definitions. Only this directory may name specific cards. +// +// Each effect is a small function that returns engine actions. Module 5 +// replaces these functions with card data the engine interprets. Auras are +// already data: the engine reads them, and no engine code knows these cards. + +import { CardLibrary, minion, spell } from "../engine"; + +/** Every card this game knows. */ +export function library(): CardLibrary { + return new CardLibrary([ + // Fictional test minion with no text. + minion("Test Filler", 1, 1), + // "Whenever a friendly minion takes damage, gain 1 Armor." + minion("Armorsmith", 1, 4, [ + { on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] }, + ]), + // "Whenever this minion takes damage, draw a card." + minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero." + minion("Test Echo", 1, 5, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] }, + ]), + // Fictional: "Whenever a hero takes damage, gain +1 Attack." + minion("Test Reactor", 1, 5, [ + { on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, + ]), + // "Deathrattle: Draw a card." + minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deathrattle: Summon a 2/1 Damaged Golem." + minion("Harvest Golem", 2, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }], + }, + ]), + // Summoned by Harvest Golem; no text. + minion("Damaged Golem", 2, 1), + // "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt. + minion("Unstable Ghoul", 1, 3, [ + { + on: "deathrattle", + effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }], + }, + ]), + // "Your minions trigger their Deathrattles twice." Two Barons still only double. + minion("Baron Rivendare", 1, 7, [], [ + { type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" }, + ]), + // "Battlecry: Draw a card." + minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + // "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.) + spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]), + ]); +} diff --git a/course/04-interceptors/reference/ts/src/engine/aura.ts b/course/04-interceptors/reference/ts/src/engine/aura.ts new file mode 100644 index 0000000..ba66ce5 --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/aura.ts @@ -0,0 +1,42 @@ +// Auras, and the one routine that applies trigger multipliers. +// +// An aura is data on a card: a continuous effect its minion has while in play. +// The engine never asks "is that card on the board?" in the middle of resolving +// something. It takes a snapshot of the auras in force at defined points (an Aura +// Update) and every rule reads the snapshot. +// +// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for +// Health/Attack auras before the Death Creation Step, and one for every other aura +// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death +// Creation Step no longer multiplies the Deathrattles of that step's Death Phase. +// Summoning a minion also runs an Aura Update. + +import type { TriggerOn } from "./card"; +import type { PlayerId, World } from "./world"; + +/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */ +export function auraUpdateOther(world: World): void { + world.setActiveAuras( + world.minionsInPlay().flatMap((id) => + world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })), + ), + ); +} + +/** + * How many times a trigger listening for `on`, on a minion `owner` controls, activates + * when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in + * force the answer is 1. Multipliers with `stacking: "max"` contribute their largest + * `times`; each `"additive"` one adds `times - 1` extra activations. + */ +export function activations(world: World, owner: PlayerId, on: TriggerOn): number { + let max = 1; + let extra = 0; + for (const active of world.activeAuras()) { + const { trigger, times, stacking } = active.aura; + if (active.owner !== owner || trigger !== on) continue; + if (stacking === "max") max = Math.max(max, times); + else extra += times - 1; + } + return max + extra; +} diff --git a/course/04-interceptors/reference/ts/src/engine/card.ts b/course/04-interceptors/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..1f2c0f7 --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/card.ts @@ -0,0 +1,91 @@ +// Card definitions as the engine sees them. The definitions themselves live in `content`. + +import type { Action, Ctx } from "./queue"; + +/** + * A card's behaviour: given where and for whom it runs, the actions to perform. + * Module 2 writes these as functions in `content`; Module 5 turns them into data. + */ +export type EffectFn = (ctx: Ctx) => Action[]; + +/** 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 + +/** 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": + return "deathrattle"; + case "battlecry": + return "battlecry"; + } +} + +/** + * 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" }; + +export interface Trigger { + on: TriggerOn; + effect: EffectFn; +} + +/** What the engine needs to know about a card. */ +export interface CardDef { + name: string; + attack: number; + health: number; + /** Triggered abilities, in printed order. */ + triggers: Trigger[]; + /** Auras this minion has while in play. */ + auras: Aura[]; + /** A spell's effect when cast; absent for minions. */ + onCast?: EffectFn; +} + +export function minion( + name: string, + attack: number, + health: number, + triggers: Trigger[] = [], + auras: Aura[] = [], +): CardDef { + return { name, attack, health, triggers, auras }; +} + +export function spell(name: string, onCast: EffectFn): CardDef { + return { name, attack: 0, health: 0, triggers: [], auras: [], onCast }; +} + +/** 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.cards.set(def.name, def); + } + + get(name: string): CardDef { + const def = this.cards.get(name); + if (!def) throw new Error(`unknown card "${name}": define it in src/content`); + return def; + } +} diff --git a/course/04-interceptors/reference/ts/src/engine/death.ts b/course/04-interceptors/reference/ts/src/engine/death.ts new file mode 100644 index 0000000..6dc715d --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/death.ts @@ -0,0 +1,21 @@ +// The Death Creation Step: the one place minions leave play. +// +// Damage and destroy effects only mark a minion (0 or less Health, or pending +// destroy). It stays in play, and keeps triggering, until the outermost Phase +// ends. Then this step removes every marked minion at once. + +import type { Event } from "./event"; +import type { World } from "./world"; + +/** + * Remove every mortally wounded or pending-destroy minion from play, simultaneously. + * Returns their Death Events in order of play; empty if nothing died. + */ +export function deathCreationStep(world: World): Event[] { + const dying = world.minionsInPlay().filter((id) => world.isDoomed(id)); + // Every position is read before anything moves: removal is simultaneous, and each + // Death Event remembers where its minion stood when the step began. + const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! })); + for (const { id, position } of deaths) world.kill(id, position); + return deaths; +} diff --git a/course/04-interceptors/reference/ts/src/engine/event.ts b/course/04-interceptors/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..a8be9c7 --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/event.ts @@ -0,0 +1,22 @@ +// What happened, in order. The engine records an event each time it changes the game. + +import type { TriggerOn } from "./card"; +import type { EntityId, PlayerId } from "./world"; + +export type Event = + | { type: "Summoned"; id: EntityId } + | { type: "Damaged"; target: EntityId; amount: number } + | { type: "Triggered"; source: EntityId; on: TriggerOn } + | { type: "Drew"; player: PlayerId; id: EntityId } + | { type: "Fatigue"; player: PlayerId } + | { type: "ArmorGained"; player: PlayerId; amount: number } + | { type: "StatsChanged"; id: EntityId; attack: number; health: number } + | { type: "SpellCast"; card: string } + /** + * A Death Event: the Death Creation Step moved `id` from play to the graveyard. + * `position` is the board index it had when the step began; its Deathrattle summons there. + */ + | { type: "Destroyed"; id: EntityId; position: number } + | { type: "DeathPhase"; round: number } + /** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */ + | { type: "Played"; id: EntityId }; diff --git a/course/04-interceptors/reference/ts/src/engine/index.ts b/course/04-interceptors/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..6b6f60d --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/index.ts @@ -0,0 +1,31 @@ +// 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. + +export { + CardLibrary, + minion, + spell, + triggerLabel, + type Aura, + type CardDef, + type EffectFn, + type Trigger, + type TriggerOn, +} from "./card"; +export type { Event } from "./event"; +export { Ctx, runSequence, summon, type Action } from "./queue"; +export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world"; diff --git a/course/04-interceptors/reference/ts/src/engine/queue.ts b/course/04-interceptors/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..c1106ca --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/queue.ts @@ -0,0 +1,201 @@ +// 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. + +import { activations, auraUpdateOther } from "./aura"; +import type { Trigger, TriggerOn } from "./card"; +import { deathCreationStep } from "./death"; +import type { Event } from "./event"; +import type { EntityId, PlayerId, World } from "./world"; + +/** Something the engine can do. Card effects return these instead of changing the world. */ +export type Action = + /** Deal `amount` to each target at once: all damage first, then the reactions. */ + | { type: "damage"; targets: EntityId[]; amount: number } + | { type: "draw"; player: PlayerId } + | { type: "gainArmor"; player: PlayerId; amount: number } + | { type: "gainAttack"; id: EntityId; amount: number } + | { type: "cast"; card: string; player: PlayerId; target?: EntityId } + /** Mark each target pending destroy. They leave play in the next Death Creation Step. */ + | { type: "destroy"; targets: EntityId[] } + /** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */ + | { type: "summon"; card: string; player: PlayerId; at?: number } + /** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */ + | { type: "play"; id: EntityId }; + +/** What an effect may look at when it runs. */ +export class Ctx { + constructor( + readonly world: World, + /** The player the effect works for. */ + readonly owner: PlayerId, + /** The minion whose trigger is resolving; undefined for a spell. */ + readonly source?: EntityId, + /** The chosen target, if any. */ + readonly target?: EntityId, + /** + * The source's board position. For a Deathrattle, the source has left play, + * and this is the position its Death Event remembered. + */ + readonly position?: number, + ) {} + + /** The minion whose trigger is resolving. */ + minion(): EntityId { + if (this.source === undefined) throw new Error("only a triggered ability has a source"); + return this.source; + } + + enemyHero(): EntityId { + return this.world.hero(this.owner === 0 ? 1 : 0); + } +} + +/** Run one Sequence: a player action and everything it causes. */ +export function runSequence(world: World, action: Action): void { + perform(world, action); + // The outermost Phase has ended. Each Death Phase is itself an outermost + // Phase, so the end-of-Phase steps run again after it, until nothing dies. + let deaths = endOfOutermostPhase(world); + for (let round = 1; deaths.length > 0; round++) { + world.record({ type: "DeathPhase", round }); + react(world, deaths); + deaths = endOfOutermostPhase(world); + } +} + +/** + * The steps that run only when the outermost Phase ends; returns the Death Events. + * Hearthstone runs an Aura Update (Health/Attack) first. The course has no + * Health/Attack auras, so that step has nothing to do and is left out. + */ +function endOfOutermostPhase(world: World): Event[] { + const deaths = deathCreationStep(world); + // After the step: a minion that just died no longer contributes its auras + // to the Death Phase that resolves these deaths. + auraUpdateOther(world); + return deaths; +} + +/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */ +export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId { + const id = world.create(card, player, "board", at); + auraUpdateOther(world); + return id; +} + +function perform(world: World, action: Action): void { + switch (action.type) { + case "damage": { + // Area damage: every Damage Event is created, in order of play, before any reaction. + const targets = [...action.targets].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); + const events: Event[] = []; + for (const target of targets) { + world.damage(target, action.amount); + events.push({ type: "Damaged", target, amount: action.amount }); + } + react(world, events); + break; + } + case "draw": + world.draw(action.player); + break; + case "gainArmor": + world.gainArmor(action.player, action.amount); + break; + case "gainAttack": + world.gainAttack(action.id, action.amount); + break; + case "destroy": + for (const target of action.targets) world.markDestroyed(target); + break; + case "summon": + summon(world, action.card, action.player, action.at); + break; + case "play": { + if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`); + // Playing is not summoning: no Aura Update here. The played minion's own + // auras join at the end of the outermost Phase. + world.moveTo(action.id, "board"); + const played: Event = { type: "Played", id: action.id }; + world.record(played); + react(world, [played]); + break; + } + case "cast": { + const effect = world.card(action.card).onCast; + if (!effect) throw new Error(`"${action.card}" is not a spell`); + world.record({ type: "SpellCast", card: action.card }); + for (const next of effect(new Ctx(world, action.player, undefined, action.target))) perform(world, next); + break; + } + } +} + +/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */ +function react(world: World, events: Event[]): void { + // Fill the Queue: for each event (already in order of play), each listener in order of play. + const queue: [EntityId, Trigger, Event][] = []; + for (const event of events) { + for (const listener of listeners(world, event)) { + for (const trigger of world.card(world.cardName(listener)).triggers) { + if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]); + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, trigger, event] of queue) { + // The interceptor: the entry was queued once; the auras in force when it + // starts decide how many times it activates, back to back. + const times = activations(world, world.ownerOf(source), trigger.on); + for (let i = 0; i < times; i++) { + world.record({ type: "Triggered", source, on: trigger.on }); + const position = event.type === "Destroyed" && event.id === source ? event.position : world.boardIndex(source); + const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position)); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } + } +} + +/** + * Who may respond to `event`, in order of play: every minion in play and, for a + * Death Event, the minion that died. It has left play, but its Deathrattle still answers. + */ +function listeners(world: World, event: Event): EntityId[] { + const inPlay = world.minionsInPlay(); + if (event.type !== "Destroyed") return inPlay; + return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!); +} + +/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */ +function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean { + if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener; + if (event.type === "Played") return on === "battlecry" && event.id === listener; + if (event.type !== "Damaged") return false; + const isMinion = !world.isHero(event.target); + switch (on) { + case "selfDamaged": + return event.target === listener; + case "friendlyMinionDamaged": + return isMinion && world.ownerOf(event.target) === world.ownerOf(listener); + case "anyMinionDamaged": + return isMinion; + case "heroDamaged": + return !isMinion; + case "deathrattle": + case "battlecry": + return false; + } +} diff --git a/course/04-interceptors/reference/ts/src/engine/world.ts b/course/04-interceptors/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..be93f98 --- /dev/null +++ b/course/04-interceptors/reference/ts/src/engine/world.ts @@ -0,0 +1,244 @@ +// 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, CardDef, CardLibrary } from "./card"; +import type { Event } from "./event"; + +/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */ +export type EntityId = number; +export type PlayerId = 0 | 1; +export type Zone = "deck" | "hand" | "board" | "graveyard"; + +/** Starting Health of each hero. */ +export const HERO_HEALTH = 30; + +interface Entity { + card: string; + owner: PlayerId; + zone: Zone; + /** Stamped each time the entity enters the board; undefined until it first does. */ + orderOfPlay?: number; + attack: number; + /** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */ + health: number; + /** Hit by a destroy effect: still in play until the Death Creation Step. */ + pendingDestroy: boolean; +} + +/** An aura in force: which player's minion has it. */ +export interface ActiveAura { + owner: PlayerId; + aura: Aura; +} + +/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */ +type Side = Record & { hero: EntityId; armor: number }; + +export class World { + /** Indexed by EntityId. */ + private readonly entities: Entity[] = []; + private readonly sides: [Side, Side]; + private nextOrderOfPlay = 0; + private events: Event[] = []; + /** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */ + private auras: ActiveAura[] = []; + + /** 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, + health: def.health, + pendingDestroy: false, + }); + this.enter(id, zone, at); + return id; + } + + /** Move an entity to the end of `zone` on its owner's side, keeping its id. */ + moveTo(id: EntityId, zone: Zone): void { + const { owner, zone: from } = this.entity(id); + const list = this.sides[owner][from]; + list.splice(list.indexOf(id), 1); + this.enter(id, zone); + } + + /** The single place an entity arrives in a zone. Entering the board starts a new order of play. */ + private enter(id: EntityId, zone: Zone, at?: number): void { + const entity = this.entity(id); + const list = this.sides[entity.owner][zone]; + list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id); + entity.zone = zone; + if (zone === "board") { + entity.orderOfPlay = ++this.nextOrderOfPlay; + this.record({ type: "Summoned", id }); + } + } + + private entity(id: EntityId): Entity { + const entity = this.entities[id]; + if (!entity) throw new Error(`unknown entity id ${id}`); + return entity; + } + + // ---- Queries ---- + + card(name: string): CardDef { + return this.library.get(name); + } + + 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; + } + + /** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */ + isDoomed(id: EntityId): boolean { + const entity = this.entity(id); + return entity.health <= 0 || entity.pendingDestroy; + } + + /** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */ + boardIndex(id: EntityId): number | undefined { + const index = this.sides[this.ownerOf(id)].board.indexOf(id); + return index < 0 ? undefined : index; + } + + armor(owner: PlayerId): number { + return this.sides[owner].armor; + } + + activeAuras(): readonly ActiveAura[] { + return this.auras; + } + + log(): readonly Event[] { + return this.events; + } + + // ---- Changes: each one records its event ---- + + record(event: Event): void { + this.events.push(event); + } + + /** Only an Aura Update calls this. */ + setActiveAuras(auras: ActiveAura[]): void { + this.auras = auras; + } + + clearLog(): void { + this.events = []; + } + + damage(target: EntityId, amount: number): void { + this.entity(target).health -= amount; + this.record({ type: "Damaged", target, amount }); + } + + /** + * Draw the top card of `player`'s deck (the first in the list). An empty deck + * records `Fatigue` and changes nothing else. + */ + draw(player: PlayerId): void { + const id = this.zone(player, "deck")[0]; + if (id === undefined) { + this.record({ type: "Fatigue", player }); + return; + } + this.moveTo(id, "hand"); + this.record({ type: "Drew", player, id }); + } + + /** Mark for destruction. Nothing leaves play until the Death Creation Step. */ + markDestroyed(id: EntityId): void { + this.entity(id).pendingDestroy = true; + } + + /** Move a minion from play to its owner's graveyard, recording its Death Event. */ + kill(id: EntityId, position: number): void { + this.entity(id).pendingDestroy = false; + this.moveTo(id, "graveyard"); + this.record({ type: "Destroyed", id, position }); + } + + gainArmor(player: PlayerId, amount: number): void { + this.sides[player].armor += amount; + this.record({ type: "ArmorGained", player, amount }); + } + + 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/04-interceptors/reference/ts/src/harness.ts b/course/04-interceptors/reference/ts/src/harness.ts new file mode 100644 index 0000000..4f6266d --- /dev/null +++ b/course/04-interceptors/reference/ts/src/harness.ts @@ -0,0 +1,90 @@ +// Test harness: the thin adapter the course tests call. It translates each +// call into engine operations and holds no rules of its own. + +import { library } from "./content"; +import { runSequence, summon, triggerLabel, World, type Event, type Zone } from "./engine"; + +export type { Zone }; + +export type Id = number; +export type Player = 0 | 1; + +/** The events the tests read from `log()` (see course/harness.md). */ +export type GameEvent = + | { type: "Summoned"; id: Id } + | { type: "Damaged"; target: Id; amount: number } + | { type: "Triggered"; source: Id; trigger: string } + | { type: "Destroyed"; id: Id } + | { type: "DeathPhase"; round: number } + | { type: "Drew"; player: Player; id: Id } + | { type: "Fatigue"; player: Player } + | { type: "ArmorGained"; player: Player; amount: number } + | { type: "StatsChanged"; id: Id; attack: number; health: number } + | { type: "SpellCast"; card: string }; + +/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */ +function toGameEvent(event: Event): GameEvent | undefined { + if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) }; + if (event.type === "Destroyed") return { type: "Destroyed", id: event.id }; + // A played minion's `Summoned` already shows it entering the board. + if (event.type === "Played") return undefined; + return { ...event }; +} + +export interface Game { + summon(card: string, player?: Player): Id; // to right end of board + addToHand(card: string, player?: Player): Id; + addToDeck(card: string, player?: Player): Id; + play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry) + zoneOf(id: Id): Zone; + board(player: Player): Id[]; // left to right + hand(player: Player): Id[]; + orderOfPlay(id: Id): number; + nameOf(id: Id): string; + // Module 2 + hero(player: Player): Id; + dealDamage(target: Id, amount: number): void; // one Sequence + cast(card: string, player?: Player, target?: Id): void; + health(id: Id): number; + attack(id: Id): number; + armor(player: Player): number; + log(): GameEvent[]; + clearLog(): void; + // Module 3 + destroy(target: Id): void; // marks pending destroy, one Sequence +} + +export function newGame(opts?: { decks?: [string[], string[]] }): Game { + const world = new World(library()); + opts?.decks?.forEach((deck, player) => { + for (const card of deck) world.create(card, player as Player, "deck"); + }); + + return { + summon: (card, player = 0) => summon(world, card, player), + addToHand: (card, player = 0) => world.create(card, player, "hand"), + addToDeck: (card, player = 0) => world.create(card, player, "deck"), + // Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves. + play: (handId) => runSequence(world, { type: "play", id: handId }), + zoneOf: (id) => world.zoneOf(id), + board: (player) => [...world.zone(player, "board")], + hand: (player) => [...world.zone(player, "hand")], + orderOfPlay: (id) => { + const order = world.orderOfPlay(id); + if (order === undefined) throw new Error(`entity ${id} has not entered play`); + return order; + }, + nameOf: (id) => world.cardName(id), + + hero: (player) => world.hero(player), + dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }), + cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }), + health: (id) => world.health(id), + attack: (id) => world.attack(id), + armor: (player) => world.armor(player), + log: () => world.log().flatMap((event) => toGameEvent(event) ?? []), + clearLog: () => world.clearLog(), + + destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }), + }; +} diff --git a/course/04-interceptors/reference/ts/tsconfig.json b/course/04-interceptors/reference/ts/tsconfig.json new file mode 100644 index 0000000..2099df7 --- /dev/null +++ b/course/04-interceptors/reference/ts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src", "tests"] +} diff --git a/course/04-interceptors/reference/ts/vitest.config.ts b/course/04-interceptors/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/04-interceptors/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/04-interceptors/scenarios.md b/course/04-interceptors/scenarios.md new file mode 100644 index 0000000..170f427 --- /dev/null +++ b/course/04-interceptors/scenarios.md @@ -0,0 +1,61 @@ +# Module 4 scenarios: Interceptors and "trigger twice" + +Each heading is a test name. The Rust test lives in `tests/rust/test_04.rs` and the TypeScript test in `tests/ts/test_04.test.ts`; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests belongs to player 0 and is summoned in the order listed, so the first one listed has the lowest order of play and stands furthest left. Each test calls `clearLog()` after its setup, so `log()` holds only the events of the action under test. Every test starts with `newGame` and a deck of 5 `Test Filler` for player 0. + +Cards used (text from `course/catalog.json`): + +- `Baron Rivendare`, 1/7: "Your minions trigger their Deathrattles twice." +- `Loot Hoarder`, 2/1: "Deathrattle: Draw a card." +- `Harvest Golem`, 2/3: "Deathrattle: Summon a 2/1 Damaged Golem." +- `Damaged Golem`, 2/1, no text. Only Harvest Golem creates it. +- `Novice Engineer`, 1/1: "Battlecry: Draw a card." +- `Whirlwind`, spell: "Deal 1 damage to ALL minions." +- `Twisting Nether`, spell: "Destroy all minions and locations." The course has no locations, so it destroys all minions (the Module 3 Expansion Drop card). +- `Test Filler` (fictional), 1/1, no text. Used to fill decks. + +A trigger multiplier is an aura: data on a card that says "triggers of this kind, belonging to my owner's minions, activate N times". The set of active multipliers is read at two points only: when a minion is summoned, and in the Aura Update (Other) that follows each Death Creation Step. A queued trigger reads that set when it starts to resolve and then activates that many times in a row, recording `Triggered{source, trigger}` for each activation, before the next queued trigger starts. Baron Rivendare's multiplier does not stack: two Barons double, they do not quadruple. + +### test_04_baron_doubles + +- Setup: Summon `Baron Rivendare` (id B), then `Loot Hoarder` (id H), then `Harvest Golem` (id G). `dealDamage(G, 2)`, which leaves G at 1 Health. +- Action: player 0 casts `Whirlwind`. +- Assertions: + - `hand(0)` has 2 cards; call them C1 and C2, in hand order. `board(0)` has 3 minions: B, then D1, then D2. + - The log is exactly, in this order: `SpellCast{card: "Whirlwind"}`, `Damaged{target: B, amount: 1}`, `Damaged{target: H, amount: 1}`, `Damaged{target: G, amount: 1}`, `Destroyed{id: H}`, `Destroyed{id: G}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C2}`, `Triggered{source: G, trigger: "deathrattle"}`, `Summoned{id: D1}`, `Triggered{source: G, trigger: "deathrattle"}`, `Summoned{id: D2}`. Each Deathrattle is queued once and activates twice back to back: the Hoarder's two activations both finish before the Golem's first one starts. + - `nameOf(D1)` and `nameOf(D2)` are `"Damaged Golem"`. + - `health(B)` is 6: Baron survives the Whirlwind, so his aura is still active when the Death Phase starts. + +### test_04_simultaneous_death_not_doubled + +- Setup: Summon `Baron Rivendare` (id B), then `Loot Hoarder` (id H). +- Action: player 0 casts `Twisting Nether`. +- Assertions: + - `hand(0)` has exactly 1 card. + - The log contains exactly one `Triggered{source: H, trigger: "deathrattle"}`. + - `zoneOf(B)` and `zoneOf(H)` are graveyard. + - Baron and the Hoarder leave play in the same Death Creation Step. The Aura Update (Other) after that step finds no Baron in play, so the Hoarder's Deathrattle activates once. + +### test_04_two_barons_do_not_stack + +- Setup: Summon `Baron Rivendare` (id B1), then another `Baron Rivendare` (id B2), then `Loot Hoarder` (id H). +- Action: `dealDamage(H, 1)`. +- Assertions: + - `hand(0)` has 2 cards; call them C1 and C2, in hand order. + - The log is exactly, in this order: `Damaged{target: H, amount: 1}`, `Destroyed{id: H}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C2}`. Two Barons give two activations, not three or four. + +### test_04_aura_timing_order + +- Setup: Summon `Loot Hoarder` (id H), then `Baron Rivendare` (id B). This time the Hoarder is older than Baron. +- Action: player 0 casts `Twisting Nether`. +- Assertions: + - `hand(0)` has exactly 1 card; call it C. + - The log is exactly, in this order: `SpellCast{card: "Twisting Nether"}`, `Destroyed{id: H}`, `Destroyed{id: B}`, `DeathPhase{round: 1}`, `Triggered{source: H, trigger: "deathrattle"}`, `Drew{player: 0, id: C}`. The Hoarder's Death Event is created before Baron's, while Baron is still on the board, but `Destroyed{id: B}` comes before the Hoarder's only `Triggered`: the multiplier is read after the whole Death Creation Step, in the Aura Update (Other), not while each Death Event is created. + +### test_04_battlecry_on_play + +- Setup: `addToHand("Novice Engineer", 0)` (id E). +- Action: `play(E)`. +- Assertions: + - `hand(0)` has exactly 1 card; call it C. + - The log is exactly, in this order: `Summoned{id: E}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C}`. Playing a minion from hand puts it on the board, then its Battlecry resolves. + - `zoneOf(E)` is board and `board(0)` is `[E]`. diff --git a/course/04-interceptors/tests/rust/test_04.rs b/course/04-interceptors/tests/rust/test_04.rs new file mode 100644 index 0000000..473f103 --- /dev/null +++ b/course/04-interceptors/tests/rust/test_04.rs @@ -0,0 +1,163 @@ +// Module 4: Interceptors and "trigger twice". Each test matches a heading in scenarios.md. +use engine::harness::*; + +fn game_with_deck() -> Game { + new_game(GameOpts { + decks: Some([vec!["Test Filler".to_string(); 5], vec![]]), + }) +} + +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 spell_cast(card: &str) -> GameEvent { + GameEvent::SpellCast { + card: card.to_string(), + } +} + +#[test] +fn test_04_baron_doubles() { + let mut g = game_with_deck(); + let b = g.summon("Baron Rivendare", 0); + let h = g.summon("Loot Hoarder", 0); + let golem = g.summon("Harvest Golem", 0); + g.deal_damage(golem, 2); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 2); + let (c1, c2) = (hand[0], hand[1]); + let board = g.board(0); + assert_eq!(board.len(), 3); + assert_eq!(board[0], b); + let (d1, d2) = (board[1], board[2]); + assert_eq!( + g.log(), + vec![ + spell_cast("Whirlwind"), + damaged(b, 1), + damaged(h, 1), + damaged(golem, 1), + GameEvent::Destroyed { id: h }, + GameEvent::Destroyed { id: golem }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c2 }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d1 }, + triggered(golem, "deathrattle"), + GameEvent::Summoned { id: d2 }, + ] + ); + assert_eq!(g.name_of(d1), "Damaged Golem"); + assert_eq!(g.name_of(d2), "Damaged Golem"); + assert_eq!(g.health(b), 6); +} + +#[test] +fn test_04_simultaneous_death_not_doubled() { + let mut g = game_with_deck(); + let b = g.summon("Baron Rivendare", 0); + let h = g.summon("Loot Hoarder", 0); + g.clear_log(); + + g.cast("Twisting Nether", 0, None); + + assert_eq!(g.hand(0).len(), 1); + let hoarder_triggers = g + .log() + .into_iter() + .filter(|e| *e == triggered(h, "deathrattle")) + .count(); + assert_eq!(hoarder_triggers, 1); + assert_eq!(g.zone_of(b), Zone::Graveyard); + assert_eq!(g.zone_of(h), Zone::Graveyard); +} + +#[test] +fn test_04_two_barons_do_not_stack() { + let mut g = game_with_deck(); + g.summon("Baron Rivendare", 0); + g.summon("Baron Rivendare", 0); + let h = g.summon("Loot Hoarder", 0); + g.clear_log(); + + g.deal_damage(h, 1); + + let hand = g.hand(0); + assert_eq!(hand.len(), 2); + let (c1, c2) = (hand[0], hand[1]); + assert_eq!( + g.log(), + vec![ + damaged(h, 1), + GameEvent::Destroyed { id: h }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c2 }, + ] + ); +} + +#[test] +fn test_04_aura_timing_order() { + let mut g = game_with_deck(); + let h = g.summon("Loot Hoarder", 0); + let b = g.summon("Baron Rivendare", 0); + g.clear_log(); + + g.cast("Twisting Nether", 0, None); + + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + assert_eq!( + g.log(), + vec![ + spell_cast("Twisting Nether"), + GameEvent::Destroyed { id: h }, + GameEvent::Destroyed { id: b }, + GameEvent::DeathPhase { round: 1 }, + triggered(h, "deathrattle"), + GameEvent::Drew { player: 0, id: c }, + ] + ); +} + +#[test] +fn test_04_battlecry_on_play() { + let mut g = game_with_deck(); + let e = g.add_to_hand("Novice Engineer", 0); + g.clear_log(); + + g.play(e); + + let hand = g.hand(0); + assert_eq!(hand.len(), 1); + let c = hand[0]; + assert_eq!( + g.log(), + vec![ + GameEvent::Summoned { id: e }, + triggered(e, "battlecry"), + GameEvent::Drew { player: 0, id: c }, + ] + ); + assert_eq!(g.zone_of(e), Zone::Board); + assert_eq!(g.board(0), vec![e]); +} diff --git a/course/04-interceptors/tests/ts/test_04.test.ts b/course/04-interceptors/tests/ts/test_04.test.ts new file mode 100644 index 0000000..560e195 --- /dev/null +++ b/course/04-interceptors/tests/ts/test_04.test.ts @@ -0,0 +1,122 @@ +// Module 4: Interceptors and "trigger twice". Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +const fillers = (n: number): string[] => Array(n).fill("Test Filler"); + +test("test_04_baron_doubles", () => { + const g = newGame({ decks: [fillers(5), []] }); + const b = g.summon("Baron Rivendare", 0); + const h = g.summon("Loot Hoarder", 0); + const golem = g.summon("Harvest Golem", 0); + g.dealDamage(golem, 2); + g.clearLog(); + + g.cast("Whirlwind", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(2); + const [c1, c2] = hand; + const board = g.board(0); + expect(board).toHaveLength(3); + expect(board[0]).toBe(b); + const [, d1, d2] = board; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Whirlwind" }, + { type: "Damaged", target: b, amount: 1 }, + { type: "Damaged", target: h, amount: 1 }, + { type: "Damaged", target: golem, amount: 1 }, + { type: "Destroyed", id: h }, + { type: "Destroyed", id: golem }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c2 }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d1 }, + { type: "Triggered", source: golem, trigger: "deathrattle" }, + { type: "Summoned", id: d2 }, + ]); + expect(g.nameOf(d1)).toBe("Damaged Golem"); + expect(g.nameOf(d2)).toBe("Damaged Golem"); + expect(g.health(b)).toBe(6); +}); + +test("test_04_simultaneous_death_not_doubled", () => { + const g = newGame({ decks: [fillers(5), []] }); + const b = g.summon("Baron Rivendare", 0); + const h = g.summon("Loot Hoarder", 0); + g.clearLog(); + + g.cast("Twisting Nether", 0); + + expect(g.hand(0)).toHaveLength(1); + const triggers = g.log().filter((e) => e.type === "Triggered" && e.source === h && e.trigger === "deathrattle"); + expect(triggers).toHaveLength(1); + expect(g.zoneOf(b)).toBe("graveyard"); + expect(g.zoneOf(h)).toBe("graveyard"); +}); + +test("test_04_two_barons_do_not_stack", () => { + const g = newGame({ decks: [fillers(5), []] }); + g.summon("Baron Rivendare", 0); + g.summon("Baron Rivendare", 0); + const h = g.summon("Loot Hoarder", 0); + g.clearLog(); + + g.dealDamage(h, 1); + + const hand = g.hand(0); + expect(hand).toHaveLength(2); + const [c1, c2] = hand; + expect(g.log()).toEqual([ + { type: "Damaged", target: h, amount: 1 }, + { type: "Destroyed", id: h }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c2 }, + ]); +}); + +test("test_04_aura_timing_order", () => { + const g = newGame({ decks: [fillers(5), []] }); + const h = g.summon("Loot Hoarder", 0); + const b = g.summon("Baron Rivendare", 0); + g.clearLog(); + + g.cast("Twisting Nether", 0); + + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + expect(g.log()).toEqual([ + { type: "SpellCast", card: "Twisting Nether" }, + { type: "Destroyed", id: h }, + { type: "Destroyed", id: b }, + { type: "DeathPhase", round: 1 }, + { type: "Triggered", source: h, trigger: "deathrattle" }, + { type: "Drew", player: 0, id: c }, + ]); +}); + +test("test_04_battlecry_on_play", () => { + const g = newGame({ decks: [fillers(5), []] }); + const e = g.addToHand("Novice Engineer", 0); + g.clearLog(); + + g.play(e); + + const hand = g.hand(0); + expect(hand).toHaveLength(1); + const c = hand[0]; + expect(g.log()).toEqual([ + { type: "Summoned", id: e }, + { type: "Triggered", source: e, trigger: "battlecry" }, + { type: "Drew", player: 0, id: c }, + ]); + expect(g.zoneOf(e)).toBe("board"); + expect(g.board(0)).toEqual([e]); +}); diff --git a/course/harness.md b/course/harness.md index 3802e15..f62cf0e 100644 --- a/course/harness.md +++ b/course/harness.md @@ -8,9 +8,10 @@ Each module adds functions; `start-module` prints the line for the module you st ## Part I (`Game`): additions per module -- M1: `newGame(opts?: { decks?: [string[], string[]] }): Game`; `summon(card: string, player?: 0|1): Id` (to right end of board); `addToHand(card, player?)`, `addToDeck(card, player?)`; `play(handId: Id): void` (Expansion Drop); `zoneOf(id): "deck"|"hand"|"board"|"graveyard"`; `board(player): Id[]` (left to right); `hand(player): Id[]`; `orderOfPlay(id): number`; `nameOf(id): string`. +- M1: `newGame(opts?: { decks?: [string[], string[]] }): Game`; `summon(card: string, player?: 0|1): Id` (to right end of board); `addToHand(card, player?)`, `addToDeck(card, player?)`; `play(handId: Id): void` (Expansion Drop; required from M4); `zoneOf(id): "deck"|"hand"|"board"|"graveyard"`; `board(player): Id[]` (left to right); `hand(player): Id[]`; `orderOfPlay(id): number`; `nameOf(id): string`. - M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`. - 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` and `Twisting Nether` (the M3 Expansion Drop card). - M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `" (Assembled)"`; throws `AssembleError` if text unchanged). ### Side by side (Part I) @@ -32,6 +33,7 @@ Each module adds functions; `start-module` prints the line for the module you st | M2 | `health(id)`, `attack(id)`, `armor(player)` | `health`, `attack`, `armor` | | M2 | `log()`, `clearLog()` | `log(&self) -> Vec`, `clear_log(&mut self)` | | M3 | `destroy(target)` | `destroy(&mut self, target: Id)` | +| M4 | `play(handId)` now required (one Sequence; Battlecry) | `play(&mut self, hand_id: Id)` | | M5 | `registerCards(defs)` | `register_cards(&mut self, defs: Vec)` | | M5 | `assemble(base, donor, half)` (throws `AssembleError`) | `assemble(...) -> Result` |