From 9c50591d3be0ae24c54847440ead02f23f6dacf9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 02:30:26 +0100 Subject: [PATCH] feat: module 02 action queue Co-Authored-By: Claude Opus 5.5 --- course/02-action-queue/expansion/README.md | 18 + .../solution/rust/src/content/mod.rs | 53 + .../solution/ts/src/content/index.ts | 34 + .../expansion/tests/rust/test_02x.rs | 22 + .../expansion/tests/ts/test_02x.test.ts | 17 + course/02-action-queue/lesson.md | 337 ++++ .../reference/rust/.course.json | 1 + .../02-action-queue/reference/rust/.gitignore | 1 + .../02-action-queue/reference/rust/Cargo.toml | 7 + .../02-action-queue/reference/rust/README.md | 13 + .../reference/rust/src/content/mod.rs | 46 + .../reference/rust/src/engine/card.rs | 96 ++ .../reference/rust/src/engine/event.rs | 38 + .../reference/rust/src/engine/mod.rs | 18 + .../reference/rust/src/engine/queue.rs | 154 ++ .../reference/rust/src/engine/world.rs | 252 +++ .../reference/rust/src/harness.rs | 139 ++ .../02-action-queue/reference/rust/src/lib.rs | 3 + .../02-action-queue/reference/ts/.course.json | 1 + .../02-action-queue/reference/ts/.gitignore | 1 + course/02-action-queue/reference/ts/README.md | 13 + .../reference/ts/package-lock.json | 1496 +++++++++++++++++ .../02-action-queue/reference/ts/package.json | 14 + .../reference/ts/src/content/index.ts | 30 + .../reference/ts/src/engine/card.ts | 66 + .../reference/ts/src/engine/event.ts | 14 + .../reference/ts/src/engine/index.ts | 22 + .../reference/ts/src/engine/queue.ts | 116 ++ .../reference/ts/src/engine/world.ts | 189 +++ .../reference/ts/src/harness.ts | 84 + .../reference/ts/tsconfig.json | 12 + .../reference/ts/vitest.config.ts | 5 + course/02-action-queue/scenarios.md | 55 + course/02-action-queue/tests/rust/test_02.rs | 141 ++ .../02-action-queue/tests/ts/test_02.test.ts | 89 + course/harness.md | 19 + 36 files changed, 3616 insertions(+) create mode 100644 course/02-action-queue/expansion/README.md create mode 100644 course/02-action-queue/expansion/solution/rust/src/content/mod.rs create mode 100644 course/02-action-queue/expansion/solution/ts/src/content/index.ts create mode 100644 course/02-action-queue/expansion/tests/rust/test_02x.rs create mode 100644 course/02-action-queue/expansion/tests/ts/test_02x.test.ts create mode 100644 course/02-action-queue/lesson.md create mode 100644 course/02-action-queue/reference/rust/.course.json create mode 100644 course/02-action-queue/reference/rust/.gitignore create mode 100644 course/02-action-queue/reference/rust/Cargo.toml create mode 100644 course/02-action-queue/reference/rust/README.md create mode 100644 course/02-action-queue/reference/rust/src/content/mod.rs create mode 100644 course/02-action-queue/reference/rust/src/engine/card.rs create mode 100644 course/02-action-queue/reference/rust/src/engine/event.rs create mode 100644 course/02-action-queue/reference/rust/src/engine/mod.rs create mode 100644 course/02-action-queue/reference/rust/src/engine/queue.rs create mode 100644 course/02-action-queue/reference/rust/src/engine/world.rs create mode 100644 course/02-action-queue/reference/rust/src/harness.rs create mode 100644 course/02-action-queue/reference/rust/src/lib.rs create mode 100644 course/02-action-queue/reference/ts/.course.json create mode 100644 course/02-action-queue/reference/ts/.gitignore create mode 100644 course/02-action-queue/reference/ts/README.md create mode 100644 course/02-action-queue/reference/ts/package-lock.json create mode 100644 course/02-action-queue/reference/ts/package.json create mode 100644 course/02-action-queue/reference/ts/src/content/index.ts create mode 100644 course/02-action-queue/reference/ts/src/engine/card.ts create mode 100644 course/02-action-queue/reference/ts/src/engine/event.ts create mode 100644 course/02-action-queue/reference/ts/src/engine/index.ts create mode 100644 course/02-action-queue/reference/ts/src/engine/queue.ts create mode 100644 course/02-action-queue/reference/ts/src/engine/world.ts create mode 100644 course/02-action-queue/reference/ts/src/harness.ts create mode 100644 course/02-action-queue/reference/ts/tsconfig.json create mode 100644 course/02-action-queue/reference/ts/vitest.config.ts create mode 100644 course/02-action-queue/scenarios.md create mode 100644 course/02-action-queue/tests/rust/test_02.rs create mode 100644 course/02-action-queue/tests/ts/test_02.test.ts diff --git a/course/02-action-queue/expansion/README.md b/course/02-action-queue/expansion/README.md new file mode 100644 index 0000000..4182c71 --- /dev/null +++ b/course/02-action-queue/expansion/README.md @@ -0,0 +1,18 @@ +# Module 2 Expansion Drop: Frothing Berserker + +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: Frothing Berserker, 3-mana 2/4, "Whenever a minion takes damage, gain +1 Attack." ([Frothing Berserker, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Frothing_Berserker)). +- Tests: `tests/rust/test_02x.rs` and `tests/ts/test_02x.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 2 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. + +## Scenario + +### test_02x_frothing_counts_each_damage + +- Setup: an empty game. Summon `Frothing Berserker` for player 0 (id F), then a `Test Filler` for player 0, then a `Test Filler` for player 1. Record `attack(F)`. +- Action: player 0 casts `Whirlwind`. +- Assertions: + - `attack(F)` is 3 more than before: three minions took damage (F itself and both Fillers, the enemy one included), so F triggers three times. + - The log has exactly three `Triggered` events whose source is F. diff --git a/course/02-action-queue/expansion/solution/rust/src/content/mod.rs b/course/02-action-queue/expansion/solution/rust/src/content/mod.rs new file mode 100644 index 0000000..2bd6546 --- /dev/null +++ b/course/02-action-queue/expansion/solution/rust/src/content/mod.rs @@ -0,0 +1,53 @@ +//! 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. + +use crate::engine::{Action, CardDef, CardLibrary, 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, + }] + }), + // Expansion Drop: "Whenever a minion takes damage, gain +1 Attack." + CardDef::minion("Frothing Berserker", 2, 4).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + ]) +} diff --git a/course/02-action-queue/expansion/solution/ts/src/content/index.ts b/course/02-action-queue/expansion/solution/ts/src/content/index.ts new file mode 100644 index 0000000..db3dc8b --- /dev/null +++ b/course/02-action-queue/expansion/solution/ts/src/content/index.ts @@ -0,0 +1,34 @@ +// 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. + +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 }] }, + ]), + // Expansion Drop: "Whenever a minion takes damage, gain +1 Attack." + minion("Frothing Berserker", 2, 4, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, + ]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + ]); +} diff --git a/course/02-action-queue/expansion/tests/rust/test_02x.rs b/course/02-action-queue/expansion/tests/rust/test_02x.rs new file mode 100644 index 0000000..abd2b0d --- /dev/null +++ b/course/02-action-queue/expansion/tests/rust/test_02x.rs @@ -0,0 +1,22 @@ +// Module 2 Expansion Drop: Frothing Berserker. Matches expansion/README.md. +use engine::harness::*; + +#[test] +fn test_02x_frothing_counts_each_damage() { + let mut g = new_game(GameOpts::default()); + let f = g.summon("Frothing Berserker", 0); + g.summon("Test Filler", 0); + g.summon("Test Filler", 1); + let before = g.attack(f); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + + assert_eq!(g.attack(f), before + 3); + let frothing_triggers = g + .log() + .iter() + .filter(|e| matches!(e, GameEvent::Triggered { source, .. } if *source == f)) + .count(); + assert_eq!(frothing_triggers, 3); +} diff --git a/course/02-action-queue/expansion/tests/ts/test_02x.test.ts b/course/02-action-queue/expansion/tests/ts/test_02x.test.ts new file mode 100644 index 0000000..b54a213 --- /dev/null +++ b/course/02-action-queue/expansion/tests/ts/test_02x.test.ts @@ -0,0 +1,17 @@ +// Module 2 Expansion Drop: Frothing Berserker. Matches expansion/README.md. +import { expect, test } from "vitest"; +import { newGame } from "../src/harness"; + +test("test_02x_frothing_counts_each_damage", () => { + const g = newGame(); + const f = g.summon("Frothing Berserker", 0); + g.summon("Test Filler", 0); + g.summon("Test Filler", 1); + const before = g.attack(f); + g.clearLog(); + + g.cast("Whirlwind", 0); + + expect(g.attack(f)).toBe(before + 3); + expect(g.log().filter((e) => e.type === "Triggered" && e.source === f)).toHaveLength(3); +}); diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md new file mode 100644 index 0000000..51ca314 --- /dev/null +++ b/course/02-action-queue/lesson.md @@ -0,0 +1,337 @@ +# Module 2: The action queue, depth-first + +In Module 0 the naive engine got Whirlwind wrong: Acolyte of Pain took its damage and drew a card before Loot Hoarder had even been hit. In this module your engine gets it right. You add heroes, Health, Attack and Armor, triggered abilities, and an event log. Above all, you add the piece that decides *when* each thing happens: an action queue that resolves one player action depth-first. + +You need your Module 1 workspace with all four Module 1 tests passing, under git. + +## Observe + +Three cards ([Armorsmith](https://hearthstone.wiki.gg/wiki/Armorsmith), [Acolyte of Pain](https://hearthstone.wiki.gg/wiki/Acolyte_of_Pain), [Whirlwind](https://hearthstone.wiki.gg/wiki/Whirlwind)): + +- Armorsmith, 2-mana 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." +- Acolyte of Pain, 3-mana 1/4: "Whenever this minion takes damage, draw a card." +- Whirlwind, 1-mana spell: "Deal 1 damage to ALL minions." + +You played Armorsmith, then Acolyte of Pain. You cast Whirlwind. Two things are true in the real game, and the naive engine of Module 0 got both wrong. + +**All the damage happens first.** For area damage, "all of the Damage/Healing Events are created in play order", and only then do the reactions resolve. The rulebook's Frothing Berserker example: "all the damage occurs, then each 'On Damage' Event is resolved based on the order of play." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). So Armorsmith and Acolyte are both damaged before either of them reacts. Armorsmith reacts twice, once for each damaged friendly minion (itself included), and Acolyte once: 2 Armor and 1 card. + +**A minion at 0 Health still reacts.** Damage does not kill a minion on the spot. The rulebook calls a minion at 0 or less Health *mortally wounded*: "mortally wounded and pending destroy are ONLY converted into dead once the outermost Phase ends!" Its Flamestrike example: "the Armorsmiths will be mortally wounded but remain in play, triggering on each Damage Event" (same source). An Acolyte with 1 Health left still draws from the Whirlwind that leaves it at 0. + +Now a third case, with two fictional test cards: + +- Test Echo, 1/5: "Whenever a minion takes damage, deal 1 damage to the enemy hero." +- Test Reactor, 1/5: "Whenever a hero takes damage, gain +1 Attack." + +You played Test Echo, then Armorsmith, then Test Reactor. Armorsmith takes 1 damage. Echo and Armorsmith both respond. Echo is older, so it goes first and damages the enemy hero, and Reactor responds to *that*. Does Reactor grow before or after Armorsmith gains its Armor? + +The rulebook answers: after Echo's damage, a newly raised event "immediately begins exploring its consequences, before resolving those remaining for the current trigger/Event." It names the strategy: "The process of resolving an event is similar to a depth-first search" (same source). Reactor grows first. Armorsmith's Armor comes last. + +## Predict + +Write your answers down before you open the discussion, for example in `notes/02.md` in your workspace. + +1. In the Echo / Armorsmith / Reactor case, write the order of the three triggers, and say what a *breadth-first* engine (one that finishes every reaction to the first damage before starting any reaction to the second) would do instead. +2. While Echo's damage is resolving, Reactor triggers. Suppose Reactor's effect summoned a new Armorsmith. Should that new Armorsmith react to the damage Armorsmith took at the start? Why or why not? +3. Your engine resolves a trigger by calling the card's effect. Should the effect change the world directly (`world.damage(hero, 1)`), or return a description of what it wants done? What does each choice make easy or hard? + +
Discussion + +1. Echo, Reactor, Armorsmith. Echo resolves fully, and fully includes the hero damage it caused and Reactor's reaction to that damage. Only then does Armorsmith, the next entry in the original Queue, resolve. A breadth-first engine would run Echo, then Armorsmith, then Reactor: it would finish the first "layer" of reactions before looking at the second. The rulebook calls out the difference: "(If multiple events could be partially resolved, that would be breadth-first)" ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). + +2. No. Rule 2a fills the Queue "with all triggers that can respond, in order of play", and Rule 2c locks it: "A Queue becomes immutable once Hearthstone starts to resolve the first entry in it." (same source). The new Armorsmith did not exist when the Queue for that damage was filled. In code: build the whole list of responding triggers first, then walk it. Don't walk the board while resolving. + +3. Returning a description (an **action**) is what makes the queue possible. If effects mutate the world directly, the engine never sees "a damage happened" as a thing it can queue, order or react to. Each card would have to call the trigger machinery itself, and sooner or later one would forget. If an effect returns `Damage { targets: [enemy hero], amount: 1 }`, the engine performs it in one place, and that one place creates the Damage Events and fills the next Queue. Every card gets the same rules for free. + +
+ +## Spec + +From the course repository, copy the module's tests into your workspace: + +```sh +node tools/start-module.mjs 02 ~/my-engine +``` + +It prints the harness functions this module adds. They are listed in full in `course/harness.md` (M2 line, and the `GameEvent` section): + +| TypeScript | Rust | +|---|---| +| `hero(player)` | `hero(&self, player: Player) -> Id` | +| `dealDamage(target, amount)` | `deal_damage(&mut self, target: Id, amount: i32)` | +| `cast(card, player?, target?)` | `cast(&mut self, card: &str, player: Player, target: Option)` | +| `health(id)`, `attack(id)`, `armor(player)` | `health(&self, id) -> i32`, `attack(...)`, `armor(&self, player) -> i32` | +| `log()`, `clearLog()` | `log(&self) -> Vec`, `clear_log(&mut self)` | + +`log()` returns every event since the game started or since the last `clearLog()`, in the order they happened. Events are tagged by `type`: `Summoned`, `Damaged{target, amount}`, `Triggered{source, trigger}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `StatsChanged{id, attack, health}`, `SpellCast{card}` (plus `Destroyed` and `DeathPhase`, which arrive in Module 3). In Rust, define `pub enum GameEvent` in `src/harness.rs` with the shape given in `course/harness.md`. Every trigger in this module reports `trigger: "damaged"`. + +Each player has a hero with 30 Health, in play from the start. `hero(player)` returns its id; `board(player)` still lists minions only. `dealDamage` and `cast` each run one complete Sequence: the action and every reaction it causes. + +The four tests (full statements in `course/02-action-queue/scenarios.md`): + +- `test_02_aoe_damage_then_triggers`: Armorsmith then Acolyte of Pain, decks of 5 `Test Filler`, Whirlwind. Both `Damaged` events (Armorsmith first) come before any `Triggered` event; Armorsmith triggers twice and Acolyte once; 2 Armor; 1 card in hand. +- `test_02_mortally_wounded_still_triggers`: an Acolyte at 1 Health (after `dealDamage(acolyte, 3)`) still draws from Whirlwind, ends at 0 Health, and is still on the board. +- `test_02_depth_first_nesting`: Test Echo, Armorsmith, Test Reactor; `dealDamage(armorsmith, 1)`. Triggers in order Echo, Reactor, Armorsmith; Reactor's `StatsChanged` before `ArmorGained`. +- `test_02_empty_deck_draw_is_fatigue`: an Acolyte with an empty deck takes 1 damage. The log is exactly `Damaged`, `Triggered`, `Fatigue`, and nothing else changes. + +The first test does **not** check how Armorsmith's two triggers and Acolyte's one are interleaved. The rulebook pins the big picture (all damage, then every reaction in order of play), and the reference engine's choice is in the Build hints, but the course's sources don't state that detail for this case clearly enough to test it. + +The depth-first test is the heart of the module. `triggered_sources` / `triggeredSources` is a small helper at the top of the test file that lists the `source` of each `Triggered` event in log order. + +```rust +#[test] +fn test_02_depth_first_nesting() { + let mut g = new_game(GameOpts::default()); + let e = g.summon("Test Echo", 0); + let s = g.summon("Armorsmith", 0); + let r = g.summon("Test Reactor", 0); + g.clear_log(); + + g.deal_damage(s, 1); + let log = g.log(); + + assert_eq!(triggered_sources(&log), vec![e, r, s]); + let reactor_grew = log + .iter() + .position(|ev| matches!(ev, GameEvent::StatsChanged { id, .. } if *id == r)) + .unwrap(); + let armor_gained = log + .iter() + .position(|ev| matches!(ev, GameEvent::ArmorGained { .. })) + .unwrap(); + assert!(reactor_grew < armor_gained); + assert_eq!(g.attack(r), 2); + assert_eq!(g.armor(0), 1); + assert_eq!(g.health(g.hero(1)), 29); +} +``` + +```ts +test("test_02_depth_first_nesting", () => { + const g = newGame(); + const e = g.summon("Test Echo", 0); + const s = g.summon("Armorsmith", 0); + const r = g.summon("Test Reactor", 0); + g.clearLog(); + + g.dealDamage(s, 1); + const log = g.log(); + + expect(triggeredSources(log)).toEqual([e, r, s]); + const reactorGrew = log.findIndex((ev) => ev.type === "StatsChanged" && ev.id === r); + const armorGained = log.findIndex((ev) => ev.type === "ArmorGained"); + expect(reactorGrew).toBeGreaterThanOrEqual(0); + expect(reactorGrew).toBeLessThan(armorGained); + expect(g.attack(r)).toBe(2); + expect(g.armor(0)).toBe(1); + expect(g.health(g.hero(1))).toBe(29); +}); +``` + +Add stubs for the new harness functions and run `cargo test` or `npm test`. The four Module 2 tests fail and the Module 1 tests still pass. That is RED. + +## Build + +Make the four tests pass without breaking Module 1's. The rules for where code goes still hold: + +- The queue, triggers, damage, drawing and the log are generic machinery: `src/engine/`. +- Armorsmith, Acolyte of Pain, Whirlwind, Test Echo and Test Reactor are cards: `src/content/`. The engine must not name them. The coupling audit checks this. +- `src/harness` stays thin. `dealDamage` and `cast` each hand one action to the engine. + +A card's behaviour can be ordinary code in this module: a function in `src/content/` that the engine calls. Module 5 turns those functions into data. What matters now is that a card's code only *describes* what it wants done, and the engine decides when it happens. + +Don't build deaths. A minion at 0 Health stays on the board, and `test_02_mortally_wounded_still_triggers` checks exactly that. Module 3 adds the step that removes it. + +
Hint 1 — nudge + +Whirlwind is one action that creates two Damage Events. Do all of the damage first, collecting the events as you go, and only then ask "who responds?". When a response itself deals damage, that damage is a new action, and it goes through the very same function. Which programming technique handles "the same function, nested inside itself, finishing before the caller carries on"? + +
+ +
Hint 2 — concept + +- **Action:** a plain value describing something the engine can do: damage these targets, draw for this player, gain Armor, gain Attack, cast this spell. Card effects return a list of actions. They never change the world themselves. +- **Perform:** the engine's one function that carries out an action. It changes the world, records events in the log and, for damage, collects the Damage Events it created. +- **Trigger:** a card ability with a condition (this minion / a friendly minion / any minion / a hero takes damage) and an effect. +- **Phase and Queue:** after a damage action, build a list of `(listener, trigger)` pairs: for each Damage Event in order, each minion in play in order of play, each of its triggers whose condition matches. Build the whole list before resolving any of it; that is the locked Queue. +- **Depth-first:** resolve the Queue in order. For each entry, call the effect and perform each returned action right away. A damage action inside opens its own Phase and resolves it completely before the outer loop moves on. Plain recursion does exactly this. +- **Order of play for area damage:** sort the targets by order of play before damaging them. Heroes are in play from the start, so give them an order of play too. + +
+ +
Hint 3 — pseudo-code + +```text +runSequence(action): + perform(action) + // Module 3 processes deaths here, once the outermost action is done + +perform(action): + match action: + Damage(targets, amount): + events = [] + for t in targets sorted by orderOfPlay: + t.health -= amount; log Damaged(t, amount) + events.push(Damaged(t, amount)) + react(events) + Draw(p): + if deck(p) empty: log Fatigue(p) + else: move top card to hand; log Drew(p, id) + GainArmor(p, n): armor[p] += n; log ArmorGained(p, n) + GainAttack(id, n): attack[id] += n; log StatsChanged(id, ...) + Cast(card, p, target): + log SpellCast(card) + for a in card.onCast(ctx(owner = p, target)): perform(a) + +react(events): // one Phase + queue = [] + for e in events: // already in order of play + for m in minionsInPlay sorted by orderOfPlay: + for trig in m.card.triggers: + if responds(trig.on, m, e): queue.push((m, trig)) + for (m, trig) in queue: // the queue is locked now + log Triggered(m, "damaged") + for a in trig.effect(ctx(owner = m.owner, source = m)): + perform(a) // depth-first + +responds(on, m, Damaged(t)): + selfDamaged -> t == m + friendlyMinionDamaged -> t is a minion and owner(t) == owner(m) + anyMinionDamaged -> t is a minion + heroDamaged -> t is a hero +``` + +With this queue order, Whirlwind on Armorsmith and Acolyte gives Armorsmith (its own damage), Armorsmith (Acolyte's damage), Acolyte. The test accepts any interleaving of those three. + +
+ +When your tests pass, compare with the reference engine in `course/02-action-queue/reference/rust/src/engine/` or `course/02-action-queue/reference/ts/src/engine/`. The resolver is `queue.rs` / `queue.ts`; the content library is a list of card definitions, one short effect each. + +> **How the real game differs** +> The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). None of these are modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor; the game has no death step until Module 3. + +## Name it + +The pattern is an **action queue resolved depth-first**. Card code describes what it wants as actions. One engine function performs them. Each Phase fills a Queue of responding triggers in order of play, locks it, and resolves each entry completely, its nested reactions included, before the next. The Liquid Fire *Make a CCG* series, which says its design "was based on the documentation in the advanced rulebook", states both rules as design goals: "All reactions occur based on the order of play of the responding cards." and "All reactions are resolved in a depth-first order (children nodes before sibling nodes)." ([Jon Parham, *Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/)). + +In the reference engine, this is the Phase: + +```rust +/// 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 world.minions_in_play() { + for trigger in &world.card(world.card_name(listener)).triggers { + if responds(world, trigger.on, listener, event) { + queue.push((listener, *trigger)); + } + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (source, trigger) in queue { + world.record(Event::Triggered { + source, + on: trigger.on, + }); + let ctx = Ctx { + world, + owner: world.owner_of(source), + source: Some(source), + target: None, + }; + 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); + } + } +} +``` + +```ts +/** 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][] = []; + for (const event of events) { + for (const listener of world.minionsInPlay()) { + for (const trigger of world.card(world.cardName(listener)).triggers) { + if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger]); + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, trigger] of queue) { + world.record({ type: "Triggered", source, on: trigger.on }); + const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source)); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } +} +``` + +Two things to notice. The call stack *is* the depth-first order: `perform` calls `react`, which calls `perform` for a nested action, and the outer loop only continues once that returns. And `run_sequence` / `runSequence` is a single entry point for every player action. That is where Module 3 will add death processing, "once the outermost Phase ends". + +This is not the queue you may know from other games. Robert Nystrom's *Event Queue* chapter, the classic description of the general pattern, says "You only need a queue when you want to decouple something in time" and offers "A common rule to avoid this is to avoid sending events from within code that's handling one" ([*Game Programming Patterns*, Event Queue](https://gameprogrammingpatterns.com/event-queue.html)). A card game breaks that rule on purpose: reactions cause reactions. It survives by fixing the order explicitly (order of play) and the depth (finish the child before the sibling). + +**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now holds damage conditions like "a friendly minion" and "a hero"; those are rules vocabulary. "Armorsmith" is not. + +## Expansion Drop + +A new card arrives: Frothing Berserker, 3-mana 2/4, "Whenever a minion takes damage, gain +1 Attack." ([Frothing Berserker](https://hearthstone.wiki.gg/wiki/Frothing_Berserker)). The rulebook used it as its example for area damage: "all the damage occurs, then each 'On Damage' Event is resolved based on the order of play." ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). + +The constraint, as always: **do not edit anything under `src/engine/`**. This time 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 2 done"`. +2. Copy the expansion test for your track from `course/02-action-queue/expansion/tests/rust/test_02x.rs` or `course/02-action-queue/expansion/tests/ts/test_02x.test.ts` into your workspace's `tests/`. Run it and watch it fail. +3. Add Frothing Berserker 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_02x_frothing_counts_each_damage`, summons Frothing Berserker and a `Test Filler` for player 0, then a `Test Filler` for player 1, and casts Whirlwind. Three minions take damage (Frothing itself and the enemy's minion included), so Frothing gains +3 Attack from three separate triggers. The full scenario is in `course/02-action-queue/expansion/README.md`. + +
Discussion: what this drop measures + +If triggers in your engine are generic (a condition from a small vocabulary plus an effect that returns actions), Frothing Berserker is one entry in your content library: + +```rust +// Expansion Drop: "Whenever a minion takes damage, gain +1 Attack." +CardDef::minion("Frothing Berserker", 2, 4).on(TriggerOn::AnyMinionDamaged, |c| { + vec![Action::GainAttack { + id: c.minion(), + amount: 1, + }] +}), +``` + +```ts +// Expansion Drop: "Whenever a minion takes damage, gain +1 Attack." +minion("Frothing Berserker", 2, 4, [ + { on: "anyMinionDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] }, +]), +``` + +That is the reference answer in `course/02-action-queue/expansion/solution/`. The drop is fair: Test Echo already needed "any minion takes damage" and Test Reactor already needed "gain Attack", so a passing Module 2 engine has both pieces. Frothing combines them. + +Shapes that fail this drop: + +- **The engine decides who reacts by name** (`if card == "Armorsmith" …`). The audit should already have caught that. If it slipped through as a flag (`gainsArmorOnFriendlyDamage`), Frothing needs a new flag, which is an engine change. +- **Triggers are only checked for the damaged minion itself** ("did *this* minion take damage?"). Armorsmith and Test Echo force you past that, but an engine that special-cased them can still lack a general "any minion" listener. +- **Damage and reactions are interleaved per target.** Frothing still ends with +3, so this test passes. But Module 0's Whirlwind test, and Module 3's deaths, will not. If your Module 2 tests pass, you don't have this bug. + +If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1. + +
+ +## Further reading + +- Jon Parham, [*Make a CCG – Action System*](https://theliquidfire.com/2017/09/11/make-a-ccg-action-system/) (The Liquid Fire, 2017). The most readable worked implementation of a Hearthstone-style reaction tree, in C#. Read `SortActions`: reactions are sorted by `priority` first and `orderOfPlay` only as the tie-breaker, which is where an engine would put Poisonous or Redemption. Also note the loop at the end, the "death reaper", which runs "to take care of any mortally wounded cards" after the root action. That is Module 3. +- Robert Nystrom, [*Event Queue*](https://gameprogrammingpatterns.com/event-queue.html) (*Game Programming Patterns*). The general pattern, and its warnings. Read the sections on feedback loops and on the world state having changed by the time an event is handled: "you have to be careful not to assume the current state of the world reflects how the world was". Your ids from Module 1 are part of the answer. +- Alchyr, [*Action Queue*](https://github.com/Alchyr/BasicMod/wiki/Action-Queue) (BasicMod wiki, Slay the Spire modding). A short practical page about a different game's queue, where the modder chooses where a new action goes: "In a card's use: add to bottom. In an action's update: add to top." Compare it with the depth-first rule: "add to top" is how Slay the Spire makes a reaction happen before the next sibling. +- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): Rule 2 (Queues), the depth-first section, and Rule DH1 on area damage. diff --git a/course/02-action-queue/reference/rust/.course.json b/course/02-action-queue/reference/rust/.course.json new file mode 100644 index 0000000..2a7438f --- /dev/null +++ b/course/02-action-queue/reference/rust/.course.json @@ -0,0 +1 @@ +{ "track": "rust", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "02" } diff --git a/course/02-action-queue/reference/rust/.gitignore b/course/02-action-queue/reference/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/course/02-action-queue/reference/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/course/02-action-queue/reference/rust/Cargo.toml b/course/02-action-queue/reference/rust/Cargo.toml new file mode 100644 index 0000000..e992026 --- /dev/null +++ b/course/02-action-queue/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/02-action-queue/reference/rust/README.md b/course/02-action-queue/reference/rust/README.md new file mode 100644 index 0000000..7fd88db --- /dev/null +++ b/course/02-action-queue/reference/rust/README.md @@ -0,0 +1,13 @@ +# Module 2 reference engine (Rust) + +The course's own engine as of the end of Module 2. It adds heroes, Health, Attack and Armor, triggered abilities, an event log, and the action queue that resolves one player action depth-first. Module 1's reference was the starting point, so a diff between the two shows what this module adds. Module 3's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `queue.rs`: actions, the context an effect sees, and the resolver. Area damage creates every Damage Event before any trigger is queued; a Queue is filled once, oldest listener first, then locked; each trigger resolves completely before the next. + - `world.rs`: entities, zones, order of play, heroes, stats, Armor and the log. Every state change records its event. + - `card.rs`: card definitions (stats, triggers, a spell's effect) and the library. + - `event.rs`: the engine's events. `src/harness.rs` translates them into the course's `GameEvent`. +- `src/content/mod.rs`: card definitions. Each effect is a small function that returns actions; Module 5 turns them into data. +- `src/harness.rs`: the adapter the course tests call. `play` is still the Module 1 Expansion Drop stub. + +Run it with the module's tests: `node tools/verify-references.mjs 02` from the course repository. diff --git a/course/02-action-queue/reference/rust/src/content/mod.rs b/course/02-action-queue/reference/rust/src/content/mod.rs new file mode 100644 index 0000000..cf70cb7 --- /dev/null +++ b/course/02-action-queue/reference/rust/src/content/mod.rs @@ -0,0 +1,46 @@ +//! 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. + +use crate::engine::{Action, CardDef, CardLibrary, 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, + }] + }), + // "Deal 1 damage to ALL minions." + CardDef::spell("Whirlwind", |c| { + vec![Action::Damage { + targets: c.world.minions_in_play(), + amount: 1, + }] + }), + ]) +} diff --git a/course/02-action-queue/reference/rust/src/engine/card.rs b/course/02-action-queue/reference/rust/src/engine/card.rs new file mode 100644 index 0000000..ca73e58 --- /dev/null +++ b/course/02-action-queue/reference/rust/src/engine/card.rs @@ -0,0 +1,96 @@ +//! 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, +} + +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", + } + } +} + +#[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, + /// 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(), + 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 + } +} + +/// 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/02-action-queue/reference/rust/src/engine/event.rs b/course/02-action-queue/reference/rust/src/engine/event.rs new file mode 100644 index 0000000..9a634b7 --- /dev/null +++ b/course/02-action-queue/reference/rust/src/engine/event.rs @@ -0,0 +1,38 @@ +//! 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, + }, +} diff --git a/course/02-action-queue/reference/rust/src/engine/mod.rs b/course/02-action-queue/reference/rust/src/engine/mod.rs new file mode 100644 index 0000000..728fa82 --- /dev/null +++ b/course/02-action-queue/reference/rust/src/engine/mod.rs @@ -0,0 +1,18 @@ +//! 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. + +mod card; +mod event; +mod queue; +mod world; + +pub use card::{CardDef, CardLibrary, EffectFn, Trigger, TriggerOn}; +pub use event::Event; +pub use queue::{run_sequence, Action, Ctx}; +pub use world::{EntityId, PlayerId, World, Zone, HERO_HEALTH}; diff --git a/course/02-action-queue/reference/rust/src/engine/queue.rs b/course/02-action-queue/reference/rust/src/engine/queue.rs new file mode 100644 index 0000000..3a8b1af --- /dev/null +++ b/course/02-action-queue/reference/rust/src/engine/queue.rs @@ -0,0 +1,154 @@ +//! 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. + +use super::card::TriggerOn; +use super::event::Event; +use super::world::{EntityId, PlayerId, World}; + +/// 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, + }, +} + +/// 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, +} + +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); + // Module 3: when the outermost Phase ends, deaths are processed here. +} + +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::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, + }); + 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 world.minions_in_play() { + for trigger in &world.card(world.card_name(listener)).triggers { + if responds(world, trigger.on, listener, event) { + queue.push((listener, *trigger)); + } + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (source, trigger) in queue { + world.record(Event::Triggered { + source, + on: trigger.on, + }); + let ctx = Ctx { + world, + owner: world.owner_of(source), + source: Some(source), + target: None, + }; + 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); + } + } +} + +/// Does a trigger listening for `on`, belonging to `listener`, respond to `event`? +fn responds(world: &World, on: TriggerOn, listener: EntityId, event: &Event) -> bool { + 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, + } +} diff --git a/course/02-action-queue/reference/rust/src/engine/world.rs b/course/02-action-queue/reference/rust/src/engine/world.rs new file mode 100644 index 0000000..e861af4 --- /dev/null +++ b/course/02-action-queue/reference/rust/src/engine/world.rs @@ -0,0 +1,252 @@ +//! 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::{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. It can drop to 0 or below: Module 2 has no death step. + health: i32, +} + +/// 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, +} + +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(), + }; + 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, + }); + // 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 { + 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, + }; + let id = self.entities.len() as EntityId; + self.entities.push(entity); + self.enter(id, zone); + 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); + } + + /// 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) { + let owner = self.entity(id).owner; + self.sides[owner as usize].zone_mut(zone).push(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 + } + + pub fn armor(&self, owner: PlayerId) -> i32 { + self.sides[owner as usize].armor + } + + pub fn log(&self) -> &[Event] { + &self.log + } + + // ---- Changes: each one records its event ---------------------------- + + pub fn record(&mut self, event: Event) { + self.log.push(event); + } + + 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 }), + } + } + + 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/02-action-queue/reference/rust/src/harness.rs b/course/02-action-queue/reference/rust/src/harness.rs new file mode 100644 index 0000000..6c3b4ba --- /dev/null +++ b/course/02-action-queue/reference/rust/src/harness.rs @@ -0,0 +1,139 @@ +//! 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, 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 From<&Event> for GameEvent { + fn from(event: &Event) -> GameEvent { + 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 }, + } + } +} + +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 { + self.world.create(card, player, Zone::Board) + } + 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. This is the Module 1 Expansion Drop: see + /// course/01-handles/expansion/README.md. + pub fn play(&mut self, _hand_id: Id) { + todo!("Module 1 Expansion Drop") + } + 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().map(GameEvent::from).collect() + } + pub fn clear_log(&mut self) { + self.world.clear_log(); + } +} diff --git a/course/02-action-queue/reference/rust/src/lib.rs b/course/02-action-queue/reference/rust/src/lib.rs new file mode 100644 index 0000000..f5868fb --- /dev/null +++ b/course/02-action-queue/reference/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod content; +pub mod engine; +pub mod harness; diff --git a/course/02-action-queue/reference/ts/.course.json b/course/02-action-queue/reference/ts/.course.json new file mode 100644 index 0000000..cdd1c90 --- /dev/null +++ b/course/02-action-queue/reference/ts/.course.json @@ -0,0 +1 @@ +{ "track": "ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "02" } diff --git a/course/02-action-queue/reference/ts/.gitignore b/course/02-action-queue/reference/ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/course/02-action-queue/reference/ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/course/02-action-queue/reference/ts/README.md b/course/02-action-queue/reference/ts/README.md new file mode 100644 index 0000000..182ac72 --- /dev/null +++ b/course/02-action-queue/reference/ts/README.md @@ -0,0 +1,13 @@ +# Module 2 reference engine (TypeScript) + +The course's own engine as of the end of Module 2. It adds heroes, Health, Attack and Armor, triggered abilities, an event log, and the action queue that resolves one player action depth-first. Module 1's reference was the starting point, so a diff between the two shows what this module adds. Module 3's reference starts from this one. + +- `src/engine/`: generic machinery. Names no cards. + - `queue.ts`: actions, the context an effect sees, and the resolver. Area damage creates every Damage Event before any trigger is queued; a Queue is filled once, oldest listener first, then locked; each trigger resolves completely before the next. + - `world.ts`: entities, zones, order of play, heroes, stats, Armor and the log. Every state change records its event. + - `card.ts`: card definitions (stats, triggers, a spell's effect) and the library. + - `event.ts`: the engine's events. `src/harness.ts` translates them into the course's `GameEvent`. +- `src/content/index.ts`: card definitions. Each effect is a small function that returns actions; Module 5 turns them into data. +- `src/harness.ts`: the adapter the course tests call. `play` is still the Module 1 Expansion Drop stub. + +Run it with the module's tests: `node tools/verify-references.mjs 02` from the course repository. diff --git a/course/02-action-queue/reference/ts/package-lock.json b/course/02-action-queue/reference/ts/package-lock.json new file mode 100644 index 0000000..8fa242d --- /dev/null +++ b/course/02-action-queue/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/02-action-queue/reference/ts/package.json b/course/02-action-queue/reference/ts/package.json new file mode 100644 index 0000000..8d2da89 --- /dev/null +++ b/course/02-action-queue/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/02-action-queue/reference/ts/src/content/index.ts b/course/02-action-queue/reference/ts/src/content/index.ts new file mode 100644 index 0000000..aad105d --- /dev/null +++ b/course/02-action-queue/reference/ts/src/content/index.ts @@ -0,0 +1,30 @@ +// 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. + +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 }] }, + ]), + // "Deal 1 damage to ALL minions." + spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]), + ]); +} diff --git a/course/02-action-queue/reference/ts/src/engine/card.ts b/course/02-action-queue/reference/ts/src/engine/card.ts new file mode 100644 index 0000000..37ce5d6 --- /dev/null +++ b/course/02-action-queue/reference/ts/src/engine/card.ts @@ -0,0 +1,66 @@ +// 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 + +/** 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"; + } +} + +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[]; + /** A spell's effect when cast; absent for minions. */ + onCast?: EffectFn; +} + +export function minion(name: string, attack: number, health: number, triggers: Trigger[] = []): CardDef { + return { name, attack, health, triggers }; +} + +export function spell(name: string, onCast: EffectFn): CardDef { + return { name, attack: 0, health: 0, triggers: [], 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/02-action-queue/reference/ts/src/engine/event.ts b/course/02-action-queue/reference/ts/src/engine/event.ts new file mode 100644 index 0000000..068dbf4 --- /dev/null +++ b/course/02-action-queue/reference/ts/src/engine/event.ts @@ -0,0 +1,14 @@ +// 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 }; diff --git a/course/02-action-queue/reference/ts/src/engine/index.ts b/course/02-action-queue/reference/ts/src/engine/index.ts new file mode 100644 index 0000000..a238af1 --- /dev/null +++ b/course/02-action-queue/reference/ts/src/engine/index.ts @@ -0,0 +1,22 @@ +// 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. + +export { + CardLibrary, + minion, + spell, + triggerLabel, + type CardDef, + type EffectFn, + type Trigger, + type TriggerOn, +} from "./card"; +export type { Event } from "./event"; +export { Ctx, runSequence, type Action } from "./queue"; +export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world"; diff --git a/course/02-action-queue/reference/ts/src/engine/queue.ts b/course/02-action-queue/reference/ts/src/engine/queue.ts new file mode 100644 index 0000000..fd66b4f --- /dev/null +++ b/course/02-action-queue/reference/ts/src/engine/queue.ts @@ -0,0 +1,116 @@ +// 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. + +import type { Trigger, TriggerOn } from "./card"; +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 }; + +/** 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 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); + // Module 3: when the outermost Phase ends, deaths are processed here. +} + +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 "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][] = []; + for (const event of events) { + for (const listener of world.minionsInPlay()) { + for (const trigger of world.card(world.cardName(listener)).triggers) { + if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger]); + } + } + } + // The Queue is now locked: nothing that happens below adds to it. + for (const [source, trigger] of queue) { + world.record({ type: "Triggered", source, on: trigger.on }); + const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source)); + // Depth-first: each action, and every reaction it causes, finishes before the next. + for (const action of actions) perform(world, action); + } +} + +/** 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 !== "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; + } +} diff --git a/course/02-action-queue/reference/ts/src/engine/world.ts b/course/02-action-queue/reference/ts/src/engine/world.ts new file mode 100644 index 0000000..e6da551 --- /dev/null +++ b/course/02-action-queue/reference/ts/src/engine/world.ts @@ -0,0 +1,189 @@ +// 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 { 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. It can drop to 0 or below: Module 2 has no death step. */ + health: number; +} + +/** 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[] = []; + + /** 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, + }); + // 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` at the end of `zone` (the right end for the board). */ + create(card: string, owner: PlayerId, zone: Zone): 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 }); + this.enter(id, zone); + 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): void { + const entity = this.entity(id); + this.sides[entity.owner][zone].push(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; + } + + armor(owner: PlayerId): number { + return this.sides[owner].armor; + } + + log(): readonly Event[] { + return this.events; + } + + // ---- Changes: each one records its event ---- + + record(event: Event): void { + this.events.push(event); + } + + 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 }); + } + + 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/02-action-queue/reference/ts/src/harness.ts b/course/02-action-queue/reference/ts/src/harness.ts new file mode 100644 index 0000000..3226642 --- /dev/null +++ b/course/02-action-queue/reference/ts/src/harness.ts @@ -0,0 +1,84 @@ +// 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, 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 }; + +function toGameEvent(event: Event): GameEvent { + if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) }; + 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; // Expansion Drop + 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; +} + +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) => world.create(card, player, "board"), + addToHand: (card, player = 0) => world.create(card, player, "hand"), + addToDeck: (card, player = 0) => world.create(card, player, "deck"), + // Module 1 Expansion Drop: see course/01-handles/expansion/README.md. + play: () => { + throw new Error("not implemented: Module 1 Expansion Drop"); + }, + 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().map(toGameEvent), + clearLog: () => world.clearLog(), + }; +} diff --git a/course/02-action-queue/reference/ts/tsconfig.json b/course/02-action-queue/reference/ts/tsconfig.json new file mode 100644 index 0000000..2099df7 --- /dev/null +++ b/course/02-action-queue/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/02-action-queue/reference/ts/vitest.config.ts b/course/02-action-queue/reference/ts/vitest.config.ts new file mode 100644 index 0000000..a6f65eb --- /dev/null +++ b/course/02-action-queue/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/02-action-queue/scenarios.md b/course/02-action-queue/scenarios.md new file mode 100644 index 0000000..b01a329 --- /dev/null +++ b/course/02-action-queue/scenarios.md @@ -0,0 +1,55 @@ +# Module 2 scenarios: The action queue, depth-first + +Each heading is a test name. The Rust test lives in `tests/rust/test_02.rs` and the TypeScript test in `tests/ts/test_02.test.ts`; both state exactly what is written here. Player 0 is the first player, player 1 the second. Every minion in these tests is summoned for player 0, in the order listed, so the first one listed has the lowest order of play. Each test calls `clearLog()` after its setup, so `log()` holds only the events of the action under test. + +Cards used (text from `course/catalog.json`): + +- `Armorsmith`, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." +- `Acolyte of Pain`, 1/4: "Whenever this minion takes damage, draw a card." +- `Whirlwind`, spell: "Deal 1 damage to ALL minions." +- `Test Echo` (fictional), 1/5: "Whenever a minion takes damage, deal 1 damage to the enemy hero." Player 0's enemy hero is `hero(1)`. +- `Test Reactor` (fictional), 1/5: "Whenever a hero takes damage, gain +1 Attack." +- `Test Filler` (fictional), 1/1, no text. Used to fill decks. + +Heroes have 30 Health and 0 Armor at the start of a game. + +### test_02_aoe_damage_then_triggers + +- Setup: `newGame` with a deck of 5 `Test Filler` for each player. Summon `Armorsmith` (id S), then `Acolyte of Pain` (id A). +- Action: player 0 casts `Whirlwind`. +- Assertions: + - The log has two `Damaged` events, for S then A (area damage is created in order of play), each of amount 1. + - Every `Damaged` event comes before every `Triggered` event in the log. + - `Triggered` events: S appears twice (once for each damaged friendly minion, itself included), A once. The test does not check how those three are interleaved. + - `armor(0)` is 2. + - `hand(0)` has 1 card. + +### test_02_mortally_wounded_still_triggers + +- Setup: `newGame` with a deck of 5 `Test Filler` for player 0. Summon `Acolyte of Pain` (id A). `dealDamage(A, 3)`, which leaves A at 1 Health and draws one card. Record `hand(0)`'s length as N. +- Action: player 0 casts `Whirlwind`. +- Assertions: + - The log contains `Triggered{source: A, trigger: "damaged"}` and a `Drew` event for player 0. + - `hand(0)` has N + 1 cards. + - `health(A)` is 0. + - `zoneOf(A)` is still board. Module 2 has no death step; Module 3 adds one. + +### test_02_depth_first_nesting + +- Setup: an empty game. Summon `Test Echo` (id E), then `Armorsmith` (id S), then `Test Reactor` (id R). +- Action: `dealDamage(S, 1)`. +- Assertions: + - The sources of the `Triggered` events, in log order, are E, R, S. E and S both respond to S taking damage. E resolves first (older), and the hero damage it deals makes R trigger and resolve before S gets its turn. + - The `StatsChanged` event for R comes before the `ArmorGained` event in the log. + - `attack(R)` is 2. + - `armor(0)` is 1. + - `health(hero(1))` is 29. + +### test_02_empty_deck_draw_is_fatigue + +- Setup: an empty game (no decks). Summon `Acolyte of Pain` (id A). +- Action: `dealDamage(A, 1)`. +- Assertions: + - The log is exactly `Damaged{target: A, amount: 1}`, `Triggered{source: A, trigger: "damaged"}`, `Fatigue{player: 0}`. + - `hand(0)` is empty. + - `health(hero(0))` is 30: in this course, Fatigue is only an event. diff --git a/course/02-action-queue/tests/rust/test_02.rs b/course/02-action-queue/tests/rust/test_02.rs new file mode 100644 index 0000000..ba28d9a --- /dev/null +++ b/course/02-action-queue/tests/rust/test_02.rs @@ -0,0 +1,141 @@ +// Module 2: The action queue, depth-first. Each test matches a heading in scenarios.md. +use engine::harness::*; + +fn fillers(n: usize) -> Vec { + vec!["Test Filler".to_string(); n] +} + +/// Sources of the `Triggered` events, in log order. +fn triggered_sources(log: &[GameEvent]) -> Vec { + log.iter() + .filter_map(|e| match e { + GameEvent::Triggered { source, .. } => Some(*source), + _ => None, + }) + .collect() +} + +#[test] +fn test_02_aoe_damage_then_triggers() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), fillers(5)]), + }); + let s = g.summon("Armorsmith", 0); + let a = g.summon("Acolyte of Pain", 0); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + let log = g.log(); + + let damaged: Vec = log + .iter() + .filter(|e| matches!(e, GameEvent::Damaged { .. })) + .cloned() + .collect(); + assert_eq!( + damaged, + vec![ + GameEvent::Damaged { + target: s, + amount: 1 + }, + GameEvent::Damaged { + target: a, + amount: 1 + }, + ] + ); + let last_damaged = log + .iter() + .rposition(|e| matches!(e, GameEvent::Damaged { .. })) + .unwrap(); + let first_triggered = log + .iter() + .position(|e| matches!(e, GameEvent::Triggered { .. })) + .unwrap(); + assert!(last_damaged < first_triggered); + + let sources = triggered_sources(&log); + assert_eq!(sources.iter().filter(|&&id| id == s).count(), 2); + assert_eq!(sources.iter().filter(|&&id| id == a).count(), 1); + assert_eq!(sources.len(), 3); + assert_eq!(g.armor(0), 2); + assert_eq!(g.hand(0).len(), 1); +} + +#[test] +fn test_02_mortally_wounded_still_triggers() { + let mut g = new_game(GameOpts { + decks: Some([fillers(5), vec![]]), + }); + let a = g.summon("Acolyte of Pain", 0); + g.deal_damage(a, 3); + let n = g.hand(0).len(); + g.clear_log(); + + g.cast("Whirlwind", 0, None); + let log = g.log(); + + assert!(log.contains(&GameEvent::Triggered { + source: a, + trigger: "damaged".to_string(), + })); + assert!(log + .iter() + .any(|e| matches!(e, GameEvent::Drew { player: 0, .. }))); + assert_eq!(g.hand(0).len(), n + 1); + assert_eq!(g.health(a), 0); + assert_eq!(g.zone_of(a), Zone::Board); +} + +#[test] +fn test_02_depth_first_nesting() { + let mut g = new_game(GameOpts::default()); + let e = g.summon("Test Echo", 0); + let s = g.summon("Armorsmith", 0); + let r = g.summon("Test Reactor", 0); + g.clear_log(); + + g.deal_damage(s, 1); + let log = g.log(); + + assert_eq!(triggered_sources(&log), vec![e, r, s]); + let reactor_grew = log + .iter() + .position(|ev| matches!(ev, GameEvent::StatsChanged { id, .. } if *id == r)) + .unwrap(); + let armor_gained = log + .iter() + .position(|ev| matches!(ev, GameEvent::ArmorGained { .. })) + .unwrap(); + assert!(reactor_grew < armor_gained); + assert_eq!(g.attack(r), 2); + assert_eq!(g.armor(0), 1); + assert_eq!(g.health(g.hero(1)), 29); +} + +#[test] +fn test_02_empty_deck_draw_is_fatigue() { + let mut g = new_game(GameOpts::default()); + let a = g.summon("Acolyte of Pain", 0); + g.clear_log(); + + g.deal_damage(a, 1); + + assert_eq!( + g.log(), + vec![ + GameEvent::Damaged { + target: a, + amount: 1 + }, + GameEvent::Triggered { + source: a, + trigger: "damaged".to_string(), + }, + GameEvent::Fatigue { player: 0 }, + ] + ); + assert_eq!(g.hand(0), Vec::::new()); + assert_eq!(g.health(g.hero(0)), 30); +} diff --git a/course/02-action-queue/tests/ts/test_02.test.ts b/course/02-action-queue/tests/ts/test_02.test.ts new file mode 100644 index 0000000..38df22a --- /dev/null +++ b/course/02-action-queue/tests/ts/test_02.test.ts @@ -0,0 +1,89 @@ +// Module 2: The action queue, depth-first. Each test matches a heading in scenarios.md. +import { expect, test } from "vitest"; +import { newGame, type GameEvent, type Id } from "../src/harness"; + +const fillers = (n: number): string[] => Array(n).fill("Test Filler"); + +/** Sources of the `Triggered` events, in log order. */ +const triggeredSources = (log: GameEvent[]): Id[] => + log.flatMap((e) => (e.type === "Triggered" ? [e.source] : [])); + +test("test_02_aoe_damage_then_triggers", () => { + const g = newGame({ decks: [fillers(5), fillers(5)] }); + const s = g.summon("Armorsmith", 0); + const a = g.summon("Acolyte of Pain", 0); + g.clearLog(); + + g.cast("Whirlwind", 0); + const log = g.log(); + + expect(log.filter((e) => e.type === "Damaged")).toEqual([ + { type: "Damaged", target: s, amount: 1 }, + { type: "Damaged", target: a, amount: 1 }, + ]); + const types = log.map((e) => e.type); + const lastDamaged = types.lastIndexOf("Damaged"); + const firstTriggered = types.indexOf("Triggered"); + expect(firstTriggered).toBeGreaterThanOrEqual(0); + expect(lastDamaged).toBeLessThan(firstTriggered); + + const sources = triggeredSources(log); + expect(sources.filter((id) => id === s)).toHaveLength(2); + expect(sources.filter((id) => id === a)).toHaveLength(1); + expect(sources).toHaveLength(3); + expect(g.armor(0)).toBe(2); + expect(g.hand(0)).toHaveLength(1); +}); + +test("test_02_mortally_wounded_still_triggers", () => { + const g = newGame({ decks: [fillers(5), []] }); + const a = g.summon("Acolyte of Pain", 0); + g.dealDamage(a, 3); + const n = g.hand(0).length; + g.clearLog(); + + g.cast("Whirlwind", 0); + const log = g.log(); + + expect(log).toContainEqual({ type: "Triggered", source: a, trigger: "damaged" }); + expect(log.some((e) => e.type === "Drew" && e.player === 0)).toBe(true); + expect(g.hand(0)).toHaveLength(n + 1); + expect(g.health(a)).toBe(0); + expect(g.zoneOf(a)).toBe("board"); +}); + +test("test_02_depth_first_nesting", () => { + const g = newGame(); + const e = g.summon("Test Echo", 0); + const s = g.summon("Armorsmith", 0); + const r = g.summon("Test Reactor", 0); + g.clearLog(); + + g.dealDamage(s, 1); + const log = g.log(); + + expect(triggeredSources(log)).toEqual([e, r, s]); + const reactorGrew = log.findIndex((ev) => ev.type === "StatsChanged" && ev.id === r); + const armorGained = log.findIndex((ev) => ev.type === "ArmorGained"); + expect(reactorGrew).toBeGreaterThanOrEqual(0); + expect(reactorGrew).toBeLessThan(armorGained); + expect(g.attack(r)).toBe(2); + expect(g.armor(0)).toBe(1); + expect(g.health(g.hero(1))).toBe(29); +}); + +test("test_02_empty_deck_draw_is_fatigue", () => { + const g = newGame(); + const a = g.summon("Acolyte of Pain", 0); + g.clearLog(); + + g.dealDamage(a, 1); + + expect(g.log()).toEqual([ + { type: "Damaged", target: a, amount: 1 }, + { type: "Triggered", source: a, trigger: "damaged" }, + { type: "Fatigue", player: 0 }, + ]); + expect(g.hand(0)).toEqual([]); + expect(g.health(g.hero(0))).toBe(30); +}); diff --git a/course/harness.md b/course/harness.md index 1133efb..3802e15 100644 --- a/course/harness.md +++ b/course/harness.md @@ -41,6 +41,25 @@ Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, Drawing from an empty deck logs `Fatigue{player}` and does nothing else; the engine must not panic. +Rust shape (settled in Module 2), defined in `src/harness.rs` with `#[derive(Debug, Clone, PartialEq, Eq)]`: + +```rust +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 }, +} +``` + +In TypeScript each event is a plain object with a `type` field, for example `{ type: "Damaged", target, amount }`. Health, Attack, Armor and damage amounts are `i32` in Rust (Health can go to 0 or below). Each player has a hero with 30 Health from the start of the game; `hero(player)` returns its id. Heroes are in play but are not in `board(player)`, which lists minions only. `cast` takes the card name; a spell is not an entity. + ## Part II (`Baba`): additions per module - M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, includes `"TEXT IS PUSH"`).