diff --git a/course/02-action-queue/lesson.md b/course/02-action-queue/lesson.md
index d2fb232..2c6b916 100644
--- a/course/02-action-queue/lesson.md
+++ b/course/02-action-queue/lesson.md
@@ -216,7 +216,7 @@ With this queue order, Whirlwind on Armorsmith and Acolyte gives Armorsmith (its
When your tests pass, compare with the reference engine in `course/02-action-queue/reference/rust/src/engine/` or `course/02-action-queue/reference/ts/src/engine/`. The resolver is `queue.rs` / `queue.ts`; the content library is a list of card definitions, one short effect each.
> **How the real game differs**
-> The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). None of these are modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor; the game has no death step until Module 3.
+> The course queues every trigger strictly in order of play. The real game has exceptions: Redemption "always Queues last", Poisonous "resolves in a queue with higher priority", and when a minion takes combat damage "the defender ALWAYS triggers first, regardless of order of play". Some area spells (the rulebook lists Lightning Storm, Elemental Destruction, Dark Iron Skulker and Lightbomb) resolve each Damage Event as soon as it is created instead of creating them all first ([Advanced rulebook](https://hearthstone.wiki.gg/wiki/Advanced_rulebook)). None of these are modelled in this course. Within the course itself: Fatigue is only an event and does nothing else; Armor is only a number, and nothing in this module damages a hero that has Armor (Module 4 makes Armor absorb damage: [A second interceptor: replacement](../04-interceptors/lesson.md#a-second-interceptor-replacement)); the game has no death step until Module 3.
## Name it
@@ -396,9 +396,9 @@ Keep using the tree. It is the most useful debugging tool you will have in Modul
In `perform`, between "an effect asked for 3 damage" and "the world changed": lower Armor by 2, lower Health by 1, and record one `Damaged` event, so Reactor triggers once. Not in the card, because every source of damage would have to remember to do it. Not in a trigger, because triggers react to events that have already happened, and this changes what happens.
-That makes Armor the course's first example of a rule that **rewrites an action before it is performed**, rather than reacting after. Module 4 builds a different rule of that family (how many times a trigger runs), and names the general one: a replacement effect. The questions you just had to answer are the ones every such rule raises. Does the event report the amount asked for or the amount that got through? (The wiki's second quote suggests "it still counts", so the event exists even if Health didn't change.) And if two rewriting rules apply to the same action, which goes first?
+That makes Armor the course's first example of a rule that **rewrites an action before it is performed**, rather than reacting after. Module 4 builds it, next to a second rule of the same kind (Bolf Ramshield's redirect), and names the general one: a replacement effect. The questions you just had to answer are the ones every such rule raises. Does the event report the amount asked for or the amount that got through? (The wiki's second quote suggests "it still counts", so the event exists even if Health didn't change.) And if two rewriting rules apply to the same action, which goes first?
-The course doesn't build this: no test damages a hero that has Armor. If you build it, decide what `Damaged.amount` means and write that down next to the event type.
+This module doesn't build it: no Module 2 test damages a hero that has Armor. [Module 4](../04-interceptors/lesson.md#a-second-interceptor-replacement) does, with the course's answers: `Damaged.amount` is the full amount, a new `ArmorLost` event comes just before it, and a redirect goes before Armor.
diff --git a/course/04-interceptors/expansion/solution/rust/src/content/mod.rs b/course/04-interceptors/expansion/solution/rust/src/content/mod.rs
index fca0c77..0dd8416 100644
--- a/course/04-interceptors/expansion/solution/rust/src/content/mod.rs
+++ b/course/04-interceptors/expansion/solution/rust/src/content/mod.rs
@@ -4,7 +4,9 @@
//! replaces these functions with card data the engine interprets. Auras are
//! already data: the engine reads them, and no engine code knows these cards.
-use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, TriggerOn};
+use crate::engine::{
+ Action, Aura, CardDef, CardLibrary, RedirectFrom, RedirectTo, Stacking, TriggerOn,
+};
/// Every card this game knows.
pub fn library() -> CardLibrary {
@@ -63,6 +65,11 @@ pub fn library() -> CardLibrary {
times: 2,
stacking: Stacking::Max,
}),
+ // "Whenever your hero takes damage, this minion takes it instead."
+ CardDef::minion("Bolf Ramshield", 3, 9).aura(Aura::DamageRedirect {
+ from: RedirectFrom::FriendlyHero,
+ to: RedirectTo::This,
+ }),
// Expansion Drop: "Your Battlecries trigger twice."
CardDef::minion("Brann Bronzebeard", 2, 4).aura(Aura::TriggerMultiplier {
trigger: TriggerOn::Battlecry,
diff --git a/course/04-interceptors/expansion/solution/ts/src/content/index.ts b/course/04-interceptors/expansion/solution/ts/src/content/index.ts
index 27e6c67..153ac7e 100644
--- a/course/04-interceptors/expansion/solution/ts/src/content/index.ts
+++ b/course/04-interceptors/expansion/solution/ts/src/content/index.ts
@@ -47,6 +47,8 @@ export function library(): CardLibrary {
minion("Baron Rivendare", 1, 7, [], [
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
]),
+ // "Whenever your hero takes damage, this minion takes it instead."
+ minion("Bolf Ramshield", 3, 9, [], [{ type: "damageRedirect", from: "friendlyHero", to: "self" }]),
// Expansion Drop: "Your Battlecries trigger twice."
minion("Brann Bronzebeard", 2, 4, [], [
{ type: "triggerMultiplier", trigger: "battlecry", times: 2, stacking: "max" },
diff --git a/course/04-interceptors/lesson.md b/course/04-interceptors/lesson.md
index 7576ad7..57b2676 100644
--- a/course/04-interceptors/lesson.md
+++ b/course/04-interceptors/lesson.md
@@ -1,6 +1,6 @@
# Module 4: Interceptors and "trigger twice"
-In Module 0 you added Baron Rivendare to the naive engine. To do it, you edited the code that runs Deathrattles. In this module you add him again, as one entry in your content, and the code that runs Deathrattles stays as it is. The engine gains one general rule: a trigger may activate more than once, and the number comes from data on cards in play. At the end, a second "trigger twice" card arrives and you add it without touching the engine.
+In Module 0 you added Baron Rivendare to the naive engine. To do it, you edited the code that runs Deathrattles. In this module you add him again, as one entry in your content, and the code that runs Deathrattles stays as it is. The engine gains one general rule: a trigger may activate more than once, and the number comes from data on cards in play. Then you build a second kind of interceptor, one that rewrites damage before it lands. At the end, a second "trigger twice" card arrives and you add it without touching the engine.
You need your Module 3 workspace with all Module 1–3 tests passing, under git. This module's tests also use two things from earlier Expansion Drops:
@@ -65,7 +65,7 @@ From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 04 ~/my-engine
```
-This module adds no new harness function, but `play` (the Module 1 Expansion Drop) is now required, with more behaviour:
+This module adds no new harness function, only one new event, `ArmorLost{player, amount}` (see [A second interceptor: replacement](#a-second-interceptor-replacement)). But `play` (the Module 1 Expansion Drop) is now required, with more behaviour:
| TypeScript | Rust |
|---|---|
@@ -73,7 +73,7 @@ This module adds no new harness function, but `play` (the Module 1 Expansion Dro
`play` runs one Sequence. The minion moves from its owner's hand to the right end of the board (`Summoned{id}`, a new order of play, as in Module 1), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). A played minion is not *summoned* in the rulebook's sense: see Build.
-The five tests (full statements in `course/04-interceptors/scenarios.md`). Every test gives player 0 a deck of five `Test Filler`.
+The nine tests (full statements in `course/04-interceptors/scenarios.md`). Every test gives player 0 a deck of five `Test Filler`. The first five are about trigger multipliers:
- `test_04_baron_doubles`: Baron, Loot Hoarder, Harvest Golem at 1 Health; Whirlwind. Exact log: each Deathrattle triggers twice, back to back (Hoarder, Hoarder, Golem, Golem). Two draws, two Damaged Golems.
- `test_04_simultaneous_death_not_doubled`: Baron and Loot Hoarder; Twisting Nether. One draw.
@@ -81,6 +81,8 @@ The five tests (full statements in `course/04-interceptors/scenarios.md`). Every
- `test_04_aura_timing_order`: Loot Hoarder, *then* Baron; Twisting Nether. Exact log: `Destroyed(Hoarder)`, `Destroyed(Baron)`, `DeathPhase 1`, then a single `Triggered(Hoarder)`.
- `test_04_battlecry_on_play`: play a Novice Engineer from hand. Exact log: `Summoned`, `Triggered(battlecry)`, `Drew`.
+The last four are about the second interceptor, built after Name it, in [A second interceptor: replacement](#a-second-interceptor-replacement): `test_04_armor_absorbs_before_health`, `test_04_redirect_replaces_hero_damage`, `test_04_redirect_applies_once` and `test_04_redirect_before_armor`.
+
The timing test is the one that pins down *when* the multiplier is read:
```rust
@@ -135,11 +137,11 @@ test("test_04_aura_timing_order", () => {
`game_with_deck`, `spell_cast` and `triggered` are small helpers at the top of the Rust test file.
-Run `cargo test` or `npm test`. The Module 4 tests fail (Baron Rivendare and Novice Engineer don't exist yet); Modules 1–3 still pass. That is RED.
+Run `cargo test` or `npm test`. The Module 4 tests fail (Baron Rivendare, Novice Engineer and Bolf Ramshield don't exist yet); Modules 1–3 still pass. That is RED.
## Build
-Make the five tests pass without breaking Modules 1–3. The rules for where code goes are stricter than before, because this is the module the course is built around:
+Make the first five tests pass without breaking Modules 1–3. The other four wait until after Name it. The rules for where code goes are stricter than before, because this is the module the course is built around:
- **Baron Rivendare is data in `src/content/`.** His card definition declares an aura: "trigger multiplier: `deathrattle`, 2 times, stacking `max`". The course's card schema already has this shape: `{kind: "aura", modifier: {type: "triggerMultiplier", trigger, times, stacking: "max" | "additive"}}` (`course/schema/card.schema.json`). Module 5 loads cards from that format; for now, write the same fields in your language.
- **One generic routine in `src/engine/` reads multipliers**, keyed by trigger kind and owner. It must not mention Baron, Deathrattle-specific doubling, or any card. If your engine contains `if deathrattle && baronInPlay`, you've rebuilt Module 0.
@@ -224,7 +226,7 @@ Decisions in the reference that the tests don't force, but the rules suggest:
The pattern is an **interceptor**, also called a modifier pipeline. A step of the engine (here, "run this trigger's effect") consults a list of modifiers before it acts. The modifiers are data contributed by whatever is in play. The step knows the *kinds* of modifier, never the cards that supply them. Adding a card that modifies the step adds data, not a branch.
-Here is the whole interceptor in the reference, the routine and the one place that calls it:
+Here is the whole interceptor in the reference, the routine and the one place that calls it. (The `continue` on the first lines skips the other kind of aura, which arrives in the next section.)
```rust
pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
@@ -235,7 +237,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
@@ -259,9 +264,10 @@ for _ in 0..times {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
@@ -302,10 +308,137 @@ The Argentum engine (an MTG rules engine in Kotlin) implements it the way this m
Hearthstone's own data appears to draw the same line. python-hearthstone's `GameTag` (the tag names from Module 0) has `EXTRA_DEATHRATTLES_BASE = 882` next to `EXTRA_DEATHRATTLES_ADDITIONAL = 1131`, and the same pair for Battlecries: `EXTRA_BATTLECRIES_BASE = 411` and `EXTRA_BATTLECRIES_ADDITIONAL = 1126` ([`enums.py`](https://github.com/HearthSim/python-hearthstone/blob/master/hearthstone/enums.py)). The file gives names and numbers and nothing else, so what follows is the course's reading of the names: a *base* count that a second Baron can't raise, and *additional* activations that add up. If that reading is right, the game met the stacking question and answered it the way this module does, with two kinds of data and one consumer.
-Argentum's Panharmonicon file also carries a ruling: "Replacement effects are unaffected by Panharmonicon's ability". That is the line between two kinds of interceptor. A trigger multiplier changes how many times a trigger fires. A **replacement effect** rewrites an event before it happens: Comprehensive Rules 614.1a, "Effects that use the word 'instead' are replacement effects." 614.5 adds the guard every rewrite system needs: "A replacement effect doesn't invoke itself repeatedly; it gets only one opportunity to affect an event". And when many modifiers touch the same values, rule 613 orders them in layers: "all applicable continuous effects are applied in a series of layers in the following order". The course needs only the first kind; the other two are the same idea (data consulted by one step, with an explicit ordering) at a larger scale.
+Argentum's Panharmonicon file also carries a ruling: "Replacement effects are unaffected by Panharmonicon's ability". That is the line between two kinds of interceptor. A trigger multiplier changes how many times a trigger fires. A **replacement effect** rewrites an event before it happens: Comprehensive Rules 614.1a, "Effects that use the word 'instead' are replacement effects." 614.5 adds the guard every rewrite system needs: "A replacement effect doesn't invoke itself repeatedly; it gets only one opportunity to affect an event". And when many modifiers touch the same values, rule 613 orders them in layers: "all applicable continuous effects are applied in a series of layers in the following order". The course builds the first two, the second in the next section; layers are the same idea (data consulted by one step, with an explicit ordering) at a larger scale.
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. Your engine now knows "aura", "trigger multiplier", "Battlecry" and "play". It must not know "Baron Rivendare".
+## A second interceptor: replacement
+
+A trigger multiplier changes how often a trigger runs. The second kind of interceptor changes what an event *is*, before it happens. Your engine already has one place where that matters: damage.
+
+### Bolf and Armor
+
+Bolf Ramshield, 6-mana 3/9 Legendary ([Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield)):
+
+> Whenever your hero takes damage, this minion takes it instead.
+
+"Whenever" reads like a trigger, but the card page's notes say otherwise (same source):
+
+- **A rewrite, not a reaction.** "it prevents the Damage Event targeting your hero and creates a new Damage Event". Bolf's ability is one of the "predamage triggers, that trigger when your hero takes damage, before the damage is taken".
+- **Several Bolfs.** "the earliest played one is the one that will intercept the damage, due to play order."
+- **A dying Bolf.** "Bolf Ramshield can still intercept damage while mortally wounded (0 or less Health) up until the current Phase ends".
+
+And Armor, the number you have kept since Module 2 ([Armor](https://hearthstone.wiki.gg/wiki/Armor)): "Damage taken will always be deducted from Armor before Health." and "Damage taken while protected by Armor still counts as taking damage for game purposes."
+
+Both rules act on the same step: one Damage, to one target, before the world changes. When both apply, one of them has to go first.
+
+### The rules
+
+Applying a Damage to one target runs the steps of a list first, in order, as the end of the outermost Phase does (Module 3):
+
+```text
+DAMAGE_STEPS = [redirect, armor]
+```
+
+- **Redirect.** Bolf's text is an aura: `{type: "damageRedirect", from: "friendlyHero", to: "self"}` (`course/schema/card.schema.json`). While it is in the aura snapshot, damage that would be dealt to its owner's hero is dealt to Bolf instead. The `Damaged` event names Bolf, so "a hero takes damage" triggers don't fire and "a minion takes damage" triggers do. With several, the oldest Bolf's (order of play) applies, and it applies at most once to one Damage.
+- **Armor.** Damage to a hero lowers its owner's Armor first, then Health. When Armor absorbed any, a new event, `ArmorLost{player, amount}`, comes just before the `Damaged` event. `Damaged` shows the full amount: the hero still took damage, even if its Health didn't change.
+
+The redirect is an aura, so it follows the Aura Update schedule like Baron: a Bolf that dies stays in the snapshot until the update after its Death Creation Step. That is the "mortally wounded" note above, for free.
+
+The four tests (full statements in `scenarios.md`):
+
+- `test_04_armor_absorbs_before_health`: Armorsmith and Test Reactor, 2 Armor. Damage of 1 to the hero: `ArmorLost` 1, `Damaged` 1, and Reactor still grows. Then damage of 5: `ArmorLost` 1, `Damaged` 5, Health 26.
+- `test_04_redirect_replaces_hero_damage`: Bolf, Armorsmith, Test Reactor; 4 damage to the hero. Exact log: `Damaged(Bolf, 4)`, then Armorsmith's trigger. The hero is untouched and Reactor doesn't trigger.
+- `test_04_redirect_applies_once`: two Bolfs. The older one takes all 3; the other is untouched.
+- `test_04_redirect_before_armor`: 1 Armor and a Bolf; 2 damage to the hero. Exact log: `Damaged(Bolf, 2)`. No `ArmorLost`, and the Armor is still there.
+
+Make the four tests pass, with the same rules as before: Bolf is data in `src/content/`, and nothing in `src/engine/` names him.
+
+Hint — concept
+
+- **One place.** Find the line in `perform` that applies damage to one target. Everything below happens there, before the world changes, so no trigger is involved.
+- **The snapshot needs the minion.** A trigger multiplier only needed the owner of the minion that has it. A redirect needs the minion itself. Store it next to each aura in force.
+- **Each step once.** Walk the list once per Damage. The rewritten Damage is never offered to the redirect again, so two Bolfs can't pass the damage back and forth. That is Magic's guard, quoted in Name it: a replacement effect "gets only one opportunity to affect an event".
+
+
+
+When your tests pass, compare with the reference: `DAMAGE_STEPS` and the function that walks it are in `queue.rs` / `queue.ts`, and the redirect lookup is `redirect_for` / `redirectFor` in `aura.rs` / `aura.ts`.
+
+```rust
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+```
+
+```ts
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+```
+
+`world.damage` lowers Health by `amount - absorbed` and records `Damaged` with the full `amount`.
+
+Decisions in the reference that the tests don't force:
+
+- **The rewritten Damage keeps its place.** Area damage creates its Damage Events in order of play of the original targets. A redirected one stays where the hero's would have been.
+- **Any damage to the hero is redirected**, from either player's cards, since the card text doesn't say whose.
+
+> **How the real game differs**
+> - The Bolf Ramshield page doesn't mention Armor. Redirect before Armor is the course's choice. It fits the page's "before the damage is taken": Armor absorbing damage is part of taking it.
+> - The real game has more predamage effects, and they have an order of their own: "Animated Armor will always reduce the damage to 1 before Bolf Ramshield intercepts it." ([Bolf Ramshield](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield)). The course has two steps.
+> - The course's Bolf is an aura, read from the snapshot. The wiki calls it a predamage trigger; the course has no predamage triggers, so it models the effect as the data it acts like.
+
+### The name
+
+This is a **replacement effect**: Comprehensive Rules 614.1a, quoted in Name it above, "Effects that use the word 'instead' are replacement effects." Bolf's text has the word. 614.5 is the guard the reference keeps by walking the list once. And the list itself is the move Stress it question 4 asks you to make for multipliers: when two modifiers touch the same event, don't let the order of the *cards* decide; fix the order of the *kinds*, once, in a list you can read.
+
+Run the coupling audit again. Your engine now knows "redirect" and "Armor". It must not know "Bolf Ramshield".
+
## Expansion Drop
A new card arrives: Brann Bronzebeard, 3-mana 2/4, "Your Battlecries trigger twice." ([Brann Bronzebeard](https://hearthstone.wiki.gg/wiki/Brann_Bronzebeard)). This is the Module 0 question: in your naive engine, Brann meant another edit to the code that runs triggers.
@@ -415,6 +548,8 @@ The reference's choice (`max + extra`) is itself only one of the defensible ones
- [Baron Rivendare, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Baron_Rivendare): the notes section, on queuing, stacking and simultaneous death.
- [Advanced rulebook, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Advanced_rulebook): the aura rules (Rule 4 and its notes): the two kinds of Aura Update, when each runs, and the repeat-trigger cards that "check before an Event starts".
+- [Bolf Ramshield, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Bolf_Ramshield): the notes section, on predamage triggers, several Bolfs and a mortally wounded Bolf.
+- [Armor, hearthstone.wiki.gg](https://hearthstone.wiki.gg/wiki/Armor): Armor absorbs before Health, and the damage still counts.
- Wizards of the Coast, [Magic: The Gathering Comprehensive Rules](https://magic.wizards.com/en/rules): 603.2c–d (triggering additional times), 614 (replacement effects, especially 614.1 and 614.5) and 613.1 (layers). Read 603.2d first: it is this module in one sentence.
- Argentum, [`Panharmonicon.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/mtg-sets/2008-2016/src/main/kotlin/com/wingedsheep/mtg/sets/definitions/kld/cards/Panharmonicon.kt) and `duplicateETBOrLTBTriggers` in [`TriggerDetector.kt`](https://github.com/wingedsheep/argentum-engine/blob/main/rules-engine/src/main/kotlin/com/wingedsheep/engine/event/TriggerDetector.kt) (the file is long; search for the function name). Compare the card file with your Baron entry, and the function with your `activations`.
- Argentum, [`docs/architecture-principles.md`](https://github.com/wingedsheep/argentum-engine/blob/main/docs/architecture-principles.md), §2.3 "Rule 613: Base State vs. Projected State" and §2.7 on replacement effects as data. The same pattern, grown to a full rules engine.
diff --git a/course/04-interceptors/reference/rust/src/content/mod.rs b/course/04-interceptors/reference/rust/src/content/mod.rs
index e9461fa..270f052 100644
--- a/course/04-interceptors/reference/rust/src/content/mod.rs
+++ b/course/04-interceptors/reference/rust/src/content/mod.rs
@@ -4,7 +4,9 @@
//! replaces these functions with card data the engine interprets. Auras are
//! already data: the engine reads them, and no engine code knows these cards.
-use crate::engine::{Action, Aura, CardDef, CardLibrary, Stacking, TriggerOn};
+use crate::engine::{
+ Action, Aura, CardDef, CardLibrary, RedirectFrom, RedirectTo, Stacking, TriggerOn,
+};
/// Every card this game knows.
pub fn library() -> CardLibrary {
@@ -63,6 +65,11 @@ pub fn library() -> CardLibrary {
times: 2,
stacking: Stacking::Max,
}),
+ // "Whenever your hero takes damage, this minion takes it instead."
+ CardDef::minion("Bolf Ramshield", 3, 9).aura(Aura::DamageRedirect {
+ from: RedirectFrom::FriendlyHero,
+ to: RedirectTo::This,
+ }),
// "Battlecry: Draw a card."
CardDef::minion("Novice Engineer", 1, 1).on(TriggerOn::Battlecry, |c| {
vec![Action::Draw { player: c.owner }]
diff --git a/course/04-interceptors/reference/rust/src/engine/aura.rs b/course/04-interceptors/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/04-interceptors/reference/rust/src/engine/aura.rs
+++ b/course/04-interceptors/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/04-interceptors/reference/rust/src/engine/card.rs b/course/04-interceptors/reference/rust/src/engine/card.rs
index e2f3dac..5d98824 100644
--- a/course/04-interceptors/reference/rust/src/engine/card.rs
+++ b/course/04-interceptors/reference/rust/src/engine/card.rs
@@ -59,6 +59,21 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RedirectTo {
+ This,
}
#[derive(Debug, Clone, Copy)]
diff --git a/course/04-interceptors/reference/rust/src/engine/event.rs b/course/04-interceptors/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/04-interceptors/reference/rust/src/engine/event.rs
+++ b/course/04-interceptors/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/04-interceptors/reference/rust/src/engine/mod.rs b/course/04-interceptors/reference/rust/src/engine/mod.rs
index 02cd843..397a88e 100644
--- a/course/04-interceptors/reference/rust/src/engine/mod.rs
+++ b/course/04-interceptors/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
mod aura;
mod card;
@@ -22,7 +23,9 @@ mod event;
mod queue;
mod world;
-pub use card::{Aura, CardDef, CardLibrary, EffectFn, Stacking, Trigger, TriggerOn};
+pub use card::{
+ Aura, CardDef, CardLibrary, EffectFn, RedirectFrom, RedirectTo, Stacking, Trigger, TriggerOn,
+};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/04-interceptors/reference/rust/src/engine/queue.rs b/course/04-interceptors/reference/rust/src/engine/queue.rs
index 7cf49f0..0c69985 100644
--- a/course/04-interceptors/reference/rust/src/engine/queue.rs
+++ b/course/04-interceptors/reference/rust/src/engine/queue.rs
@@ -12,8 +12,12 @@
//! Module 4: a queued trigger may activate more than once. How many times is
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
+//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::TriggerOn;
use super::death::death_creation_step;
use super::event::Event;
@@ -60,6 +64,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -129,8 +148,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -181,6 +199,36 @@ fn perform(world: &mut World, action: Action) {
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/04-interceptors/reference/rust/src/engine/world.rs b/course/04-interceptors/reference/rust/src/engine/world.rs
index 49f8495..f44ca59 100644
--- a/course/04-interceptors/reference/rust/src/engine/world.rs
+++ b/course/04-interceptors/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -296,11 +297,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/04-interceptors/reference/rust/src/harness.rs b/course/04-interceptors/reference/rust/src/harness.rs
index c2fc11f..afbbeae 100644
--- a/course/04-interceptors/reference/rust/src/harness.rs
+++ b/course/04-interceptors/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/04-interceptors/reference/ts/src/content/index.ts b/course/04-interceptors/reference/ts/src/content/index.ts
index be97b31..147906e 100644
--- a/course/04-interceptors/reference/ts/src/content/index.ts
+++ b/course/04-interceptors/reference/ts/src/content/index.ts
@@ -47,6 +47,8 @@ export function library(): CardLibrary {
minion("Baron Rivendare", 1, 7, [], [
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
]),
+ // "Whenever your hero takes damage, this minion takes it instead."
+ minion("Bolf Ramshield", 3, 9, [], [{ type: "damageRedirect", from: "friendlyHero", to: "self" }]),
// "Battlecry: Draw a card."
minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]),
// "Deal 1 damage to ALL minions."
diff --git a/course/04-interceptors/reference/ts/src/engine/aura.ts b/course/04-interceptors/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/04-interceptors/reference/ts/src/engine/aura.ts
+++ b/course/04-interceptors/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/04-interceptors/reference/ts/src/engine/card.ts b/course/04-interceptors/reference/ts/src/engine/card.ts
index 1f2c0f7..85b36bd 100644
--- a/course/04-interceptors/reference/ts/src/engine/card.ts
+++ b/course/04-interceptors/reference/ts/src/engine/card.ts
@@ -40,8 +40,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
export interface Trigger {
on: TriggerOn;
diff --git a/course/04-interceptors/reference/ts/src/engine/event.ts b/course/04-interceptors/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/04-interceptors/reference/ts/src/engine/event.ts
+++ b/course/04-interceptors/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/04-interceptors/reference/ts/src/engine/index.ts b/course/04-interceptors/reference/ts/src/engine/index.ts
index 6b6f60d..dff37ec 100644
--- a/course/04-interceptors/reference/ts/src/engine/index.ts
+++ b/course/04-interceptors/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
export {
CardLibrary,
@@ -27,5 +28,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/04-interceptors/reference/ts/src/engine/queue.ts b/course/04-interceptors/reference/ts/src/engine/queue.ts
index 7569ba0..17a4583 100644
--- a/course/04-interceptors/reference/ts/src/engine/queue.ts
+++ b/course/04-interceptors/reference/ts/src/engine/queue.ts
@@ -12,8 +12,12 @@
// Module 4: a queued trigger may activate more than once. How many times is
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
+//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import type { Event } from "./event";
@@ -34,6 +38,21 @@ export type Action =
/** Play a minion from its owner's hand: it enters the right end of the board, then its Battlecry resolves. */
| { type: "play"; id: EntityId };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -101,10 +120,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -143,6 +159,31 @@ function perform(world: World, action: Action): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/04-interceptors/reference/ts/src/engine/world.ts b/course/04-interceptors/reference/ts/src/engine/world.ts
index 61c2e2c..22e80f7 100644
--- a/course/04-interceptors/reference/ts/src/engine/world.ts
+++ b/course/04-interceptors/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -221,11 +222,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/04-interceptors/reference/ts/src/harness.ts b/course/04-interceptors/reference/ts/src/harness.ts
index fbda787..1a876cf 100644
--- a/course/04-interceptors/reference/ts/src/harness.ts
+++ b/course/04-interceptors/reference/ts/src/harness.ts
@@ -19,6 +19,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/04-interceptors/scenarios.md b/course/04-interceptors/scenarios.md
index 4f5c662..01f2db1 100644
--- a/course/04-interceptors/scenarios.md
+++ b/course/04-interceptors/scenarios.md
@@ -12,9 +12,17 @@ Cards used (text from `course/catalog.json`):
- `Whirlwind`, spell: "Deal 1 damage to ALL minions."
- `Twisting Nether`, spell: "Destroy all minions and locations." The course has no locations, so it destroys all minions (the Module 3 Expansion Drop card).
- `Test Filler` (fictional), 1/1, no text. Used to fill decks.
+- `Bolf Ramshield`, 3/9: "Whenever your hero takes damage, this minion takes it instead."
+- `Armorsmith`, 1/4: "Whenever a friendly minion takes damage, gain 1 Armor." (Module 2)
+- `Test Reactor` (fictional), 1/5: "Whenever a hero takes damage, gain +1 Attack." (Module 2)
A trigger multiplier is an aura: data on a card that says "triggers of this kind, belonging to my owner's minions, activate N times". The set of active multipliers is read at two points only: when a minion is summoned, and in the Aura Update (Other) that follows each Death Creation Step. A queued trigger reads that set when it starts to resolve and then activates that many times in a row, recording `Triggered{source, trigger}` for each activation, before the next queued trigger starts. Baron Rivendare's multiplier does not stack: two Barons double, they do not quadruple.
+The last four tests are about a second interceptor, replacement. Applying a Damage to one target runs the steps of `DAMAGE_STEPS` first, in this order, before the world changes:
+
+1. **Redirect.** Bolf Ramshield's text is an aura, `{type: "damageRedirect", from: "friendlyHero", to: "self"}`. While it is in the aura snapshot, damage that would be dealt to its owner's hero is dealt to Bolf instead, and the `Damaged` event names Bolf. With several, the oldest one's (order of play) applies. It applies at most once to one Damage.
+2. **Armor.** Damage to a hero lowers its owner's Armor first, then Health. A new event, `ArmorLost{player, amount}`, is recorded just before the `Damaged` event when Armor absorbed any. The `Damaged` event shows the full amount: the hero still took damage, even if Health didn't change.
+
### test_04_baron_doubles
- Setup: Summon `Baron Rivendare` (id B), then `Loot Hoarder` (id H), then `Harvest Golem` (id G). `dealDamage(G, 2)`, which leaves G at 1 Health.
@@ -60,3 +68,39 @@ A trigger multiplier is an aura: data on a card that says "triggers of this kind
- `hand(0)` has exactly 1 card; call it C.
- The log is exactly, in this order: `Summoned{id: E}`, `Triggered{source: E, trigger: "battlecry"}`, `Drew{player: 0, id: C}`. Playing a minion from hand puts it on the board, then its Battlecry resolves.
- `zoneOf(E)` is board and `board(0)` is `[E]`.
+
+### test_04_armor_absorbs_before_health
+
+- Setup: Summon `Armorsmith` (id A), then `Test Reactor` (id R). `dealDamage(A, 1)` twice, which gives player 0 2 Armor. Let H be `hero(0)`.
+- Action 1: `dealDamage(H, 1)`.
+- Assertions:
+ - The log is exactly, in this order: `ArmorLost{player: 0, amount: 1}`, `Damaged{target: H, amount: 1}`, `Triggered{source: R, trigger: "damaged"}`, `StatsChanged{id: R, attack: 2, health: 5}`. Armor took all of it, and the hero still took damage: Reactor triggers.
+ - `armor(0)` is 1 and `health(H)` is 30.
+- `clearLog()`, then Action 2: `dealDamage(H, 5)`.
+- Assertions:
+ - The log is exactly, in this order: `ArmorLost{player: 0, amount: 1}`, `Damaged{target: H, amount: 5}`, `Triggered{source: R, trigger: "damaged"}`, `StatsChanged{id: R, attack: 3, health: 5}`. `Damaged` shows the full 5, not the 4 that got through.
+ - `armor(0)` is 0 and `health(H)` is 26.
+
+### test_04_redirect_replaces_hero_damage
+
+- Setup: Summon `Bolf Ramshield` (id B), then `Armorsmith` (id A), then `Test Reactor` (id R). Let H be `hero(0)`.
+- Action: `dealDamage(H, 4)`.
+- Assertions:
+ - The log is exactly, in this order: `Damaged{target: B, amount: 4}`, `Triggered{source: A, trigger: "damaged"}`, `ArmorGained{player: 0, amount: 1}`. The Damage was rewritten before it happened: no hero took damage, so Reactor does not trigger, and a friendly minion did, so Armorsmith does.
+ - `health(H)` is 30, `health(B)` is 5, `armor(0)` is 1.
+
+### test_04_redirect_applies_once
+
+- Setup: Summon `Bolf Ramshield` (id B1), then another `Bolf Ramshield` (id B2). Let H be `hero(0)`.
+- Action: `dealDamage(H, 3)`.
+- Assertions:
+ - The log is exactly `Damaged{target: B1, amount: 3}`. The older Bolf takes all of it; the rewritten Damage is not offered to B2.
+ - `health(B1)` is 6, `health(B2)` is 9, `health(H)` is 30.
+
+### test_04_redirect_before_armor
+
+- Setup: Summon `Armorsmith` (id A). `dealDamage(A, 1)`, which gives player 0 1 Armor. `destroy(A)`. Summon `Bolf Ramshield` (id B). Let H be `hero(0)`.
+- Action: `dealDamage(H, 2)`.
+- Assertions:
+ - The log is exactly `Damaged{target: B, amount: 2}`. There is no `ArmorLost`: the redirect runs first, and Armor protects only a hero that takes the damage.
+ - `armor(0)` is 1, `health(H)` is 30, `health(B)` is 7.
diff --git a/course/04-interceptors/tests/rust/test_04.rs b/course/04-interceptors/tests/rust/test_04.rs
index d20f383..72b7f54 100644
--- a/course/04-interceptors/tests/rust/test_04.rs
+++ b/course/04-interceptors/tests/rust/test_04.rs
@@ -175,3 +175,120 @@ fn test_04_battlecry_on_play() {
assert_eq!(g.zone_of(e), Zone::Board);
assert_eq!(g.board(0), vec![e]);
}
+
+#[test]
+fn test_04_armor_absorbs_before_health() {
+ let mut g = game_with_deck();
+ let a = g.summon("Armorsmith", 0);
+ let r = g.summon("Test Reactor", 0);
+ g.deal_damage(a, 1);
+ g.deal_damage(a, 1);
+ let hero = g.hero(0);
+ assert_eq!(g.armor(0), 2);
+ g.clear_log();
+
+ g.deal_damage(hero, 1);
+
+ assert_eq!(
+ g.log(),
+ vec![
+ GameEvent::ArmorLost {
+ player: 0,
+ amount: 1
+ },
+ damaged(hero, 1),
+ triggered(r, "damaged"),
+ GameEvent::StatsChanged {
+ id: r,
+ attack: 2,
+ health: 5
+ },
+ ]
+ );
+ assert_eq!(g.armor(0), 1);
+ assert_eq!(g.health(hero), 30);
+ g.clear_log();
+
+ g.deal_damage(hero, 5);
+
+ assert_eq!(
+ g.log(),
+ vec![
+ GameEvent::ArmorLost {
+ player: 0,
+ amount: 1
+ },
+ damaged(hero, 5),
+ triggered(r, "damaged"),
+ GameEvent::StatsChanged {
+ id: r,
+ attack: 3,
+ health: 5
+ },
+ ]
+ );
+ assert_eq!(g.armor(0), 0);
+ assert_eq!(g.health(hero), 26);
+}
+
+#[test]
+fn test_04_redirect_replaces_hero_damage() {
+ let mut g = game_with_deck();
+ let b = g.summon("Bolf Ramshield", 0);
+ let a = g.summon("Armorsmith", 0);
+ g.summon("Test Reactor", 0);
+ let hero = g.hero(0);
+ g.clear_log();
+
+ g.deal_damage(hero, 4);
+
+ assert_eq!(
+ g.log(),
+ vec![
+ damaged(b, 4),
+ triggered(a, "damaged"),
+ GameEvent::ArmorGained {
+ player: 0,
+ amount: 1
+ },
+ ]
+ );
+ assert_eq!(g.health(hero), 30);
+ assert_eq!(g.health(b), 5);
+ assert_eq!(g.armor(0), 1);
+}
+
+#[test]
+fn test_04_redirect_applies_once() {
+ let mut g = game_with_deck();
+ let b1 = g.summon("Bolf Ramshield", 0);
+ let b2 = g.summon("Bolf Ramshield", 0);
+ let hero = g.hero(0);
+ g.clear_log();
+
+ g.deal_damage(hero, 3);
+
+ assert_eq!(g.log(), vec![damaged(b1, 3)]);
+ assert_eq!(g.health(b1), 6);
+ assert_eq!(g.health(b2), 9);
+ assert_eq!(g.health(hero), 30);
+}
+
+#[test]
+fn test_04_redirect_before_armor() {
+ let mut g = game_with_deck();
+ let a = g.summon("Armorsmith", 0);
+ g.deal_damage(a, 1);
+ g.destroy(a);
+ let b = g.summon("Bolf Ramshield", 0);
+ let hero = g.hero(0);
+ assert_eq!(g.armor(0), 1);
+ g.clear_log();
+
+ g.deal_damage(hero, 2);
+
+ assert_eq!(g.log(), vec![damaged(b, 2)]);
+ assert_eq!(g.armor(0), 1);
+ assert_eq!(g.health(hero), 30);
+ assert_eq!(g.health(b), 7);
+}
diff --git a/course/04-interceptors/tests/ts/test_04.test.ts b/course/04-interceptors/tests/ts/test_04.test.ts
index 9981fb5..2ac6491 100644
--- a/course/04-interceptors/tests/ts/test_04.test.ts
+++ b/course/04-interceptors/tests/ts/test_04.test.ts
@@ -123,3 +123,90 @@ test("test_04_battlecry_on_play", () => {
expect(g.zoneOf(e)).toBe("board");
expect(g.board(0)).toEqual([e]);
});
+
+test("test_04_armor_absorbs_before_health", () => {
+ const g = newGame({ decks: [fillers(5), []] });
+ const a = g.summon("Armorsmith", 0);
+ const r = g.summon("Test Reactor", 0);
+ g.dealDamage(a, 1);
+ g.dealDamage(a, 1);
+ const hero = g.hero(0);
+ expect(g.armor(0)).toBe(2);
+ g.clearLog();
+
+ g.dealDamage(hero, 1);
+
+ expect(g.log()).toEqual([
+ { type: "ArmorLost", player: 0, amount: 1 },
+ { type: "Damaged", target: hero, amount: 1 },
+ { type: "Triggered", source: r, trigger: "damaged" },
+ { type: "StatsChanged", id: r, attack: 2, health: 5 },
+ ]);
+ expect(g.armor(0)).toBe(1);
+ expect(g.health(hero)).toBe(30);
+ g.clearLog();
+
+ g.dealDamage(hero, 5);
+
+ expect(g.log()).toEqual([
+ { type: "ArmorLost", player: 0, amount: 1 },
+ { type: "Damaged", target: hero, amount: 5 },
+ { type: "Triggered", source: r, trigger: "damaged" },
+ { type: "StatsChanged", id: r, attack: 3, health: 5 },
+ ]);
+ expect(g.armor(0)).toBe(0);
+ expect(g.health(hero)).toBe(26);
+});
+
+test("test_04_redirect_replaces_hero_damage", () => {
+ const g = newGame({ decks: [fillers(5), []] });
+ const b = g.summon("Bolf Ramshield", 0);
+ const a = g.summon("Armorsmith", 0);
+ g.summon("Test Reactor", 0);
+ const hero = g.hero(0);
+ g.clearLog();
+
+ g.dealDamage(hero, 4);
+
+ expect(g.log()).toEqual([
+ { type: "Damaged", target: b, amount: 4 },
+ { type: "Triggered", source: a, trigger: "damaged" },
+ { type: "ArmorGained", player: 0, amount: 1 },
+ ]);
+ expect(g.health(hero)).toBe(30);
+ expect(g.health(b)).toBe(5);
+ expect(g.armor(0)).toBe(1);
+});
+
+test("test_04_redirect_applies_once", () => {
+ const g = newGame({ decks: [fillers(5), []] });
+ const b1 = g.summon("Bolf Ramshield", 0);
+ const b2 = g.summon("Bolf Ramshield", 0);
+ const hero = g.hero(0);
+ g.clearLog();
+
+ g.dealDamage(hero, 3);
+
+ expect(g.log()).toEqual([{ type: "Damaged", target: b1, amount: 3 }]);
+ expect(g.health(b1)).toBe(6);
+ expect(g.health(b2)).toBe(9);
+ expect(g.health(hero)).toBe(30);
+});
+
+test("test_04_redirect_before_armor", () => {
+ const g = newGame({ decks: [fillers(5), []] });
+ const a = g.summon("Armorsmith", 0);
+ g.dealDamage(a, 1);
+ g.destroy(a);
+ const b = g.summon("Bolf Ramshield", 0);
+ const hero = g.hero(0);
+ expect(g.armor(0)).toBe(1);
+ g.clearLog();
+
+ g.dealDamage(hero, 2);
+
+ expect(g.log()).toEqual([{ type: "Damaged", target: b, amount: 2 }]);
+ expect(g.armor(0)).toBe(1);
+ expect(g.health(hero)).toBe(30);
+ expect(g.health(b)).toBe(7);
+});
diff --git a/course/05-assemble/lesson.md b/course/05-assemble/lesson.md
index 494fce6..6bb0511 100644
--- a/course/05-assemble/lesson.md
+++ b/course/05-assemble/lesson.md
@@ -84,7 +84,7 @@ A card is a name, a type (`minion` or `spell`), a cost, Attack and Health for mi
}
```
-- `text` is a list of **clauses**. A clause is a `keyword` (for example `taunt`), a `trigger` (`on`: `deathrattle`, `battlecry`, `selfDamaged`, `friendlyMinionDamaged`, `anyMinionDamaged`, `heroDamaged`, `endOfTurn`), an `aura` (the Module 4 trigger multiplier, now in its final form) or an `effect`.
+- `text` is a list of **clauses**. A clause is a `keyword` (for example `taunt`), a `trigger` (`on`: `deathrattle`, `battlecry`, `selfDamaged`, `friendlyMinionDamaged`, `anyMinionDamaged`, `heroDamaged`, `endOfTurn`), an `aura` (one of Module 4's two kinds: the trigger multiplier, now in its final form, or Bolf Ramshield's `damageRedirect`) or an `effect`.
- A trigger clause applies to the effect clauses after it, until the next trigger, keyword or aura. Effect clauses before any trigger are what a spell does when cast.
- Effects: `dealDamage{target, amount}`, `draw{count}`, `summon{card, count}`, `gainArmor{amount}`, `gainAttack{target, amount}`, `destroy{target}`, and two new ones, `assemble{filter}` and `buffHand{attack, health}`. Targets: `self`, `allMinions`, `allOtherMinions`, `allOtherCharacters`, `enemyHero`, `chosen`. (`spawnText` belongs to Part III; you can ignore it until then.)
- **`split` is the course's definition of "half"**: the top half is `text[0:split]`, the bottom half `text[split:]`. Each card's data says where its top half ends. For the course's trigger cards that is right after the trigger, so "Deathrattle:" is the top and "Summon a 2/1 Damaged Golem." the bottom. Baron Rivendare's single aura clause has `split` 0: all bottom.
diff --git a/course/05-assemble/reference/rust/src/engine/aura.rs b/course/05-assemble/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/05-assemble/reference/rust/src/engine/aura.rs
+++ b/course/05-assemble/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/05-assemble/reference/rust/src/engine/card.rs b/course/05-assemble/reference/rust/src/engine/card.rs
index 8171ec2..caa66ed 100644
--- a/course/05-assemble/reference/rust/src/engine/card.rs
+++ b/course/05-assemble/reference/rust/src/engine/card.rs
@@ -65,6 +65,23 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/05-assemble/reference/rust/src/engine/event.rs b/course/05-assemble/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/05-assemble/reference/rust/src/engine/event.rs
+++ b/course/05-assemble/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/05-assemble/reference/rust/src/engine/mod.rs b/course/05-assemble/reference/rust/src/engine/mod.rs
index ff57859..384e6a0 100644
--- a/course/05-assemble/reference/rust/src/engine/mod.rs
+++ b/course/05-assemble/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -30,9 +31,9 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/05-assemble/reference/rust/src/engine/queue.rs b/course/05-assemble/reference/rust/src/engine/queue.rs
index 68c9127..619fcf1 100644
--- a/course/05-assemble/reference/rust/src/engine/queue.rs
+++ b/course/05-assemble/reference/rust/src/engine/queue.rs
@@ -13,12 +13,16 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -77,6 +81,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -217,6 +235,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/05-assemble/reference/rust/src/engine/world.rs b/course/05-assemble/reference/rust/src/engine/world.rs
index a320da6..744a7fb 100644
--- a/course/05-assemble/reference/rust/src/engine/world.rs
+++ b/course/05-assemble/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -351,11 +352,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/05-assemble/reference/rust/src/harness.rs b/course/05-assemble/reference/rust/src/harness.rs
index 84d47ac..8d1ec4a 100644
--- a/course/05-assemble/reference/rust/src/harness.rs
+++ b/course/05-assemble/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/05-assemble/reference/ts/src/engine/aura.ts b/course/05-assemble/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/05-assemble/reference/ts/src/engine/aura.ts
+++ b/course/05-assemble/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/05-assemble/reference/ts/src/engine/card.ts b/course/05-assemble/reference/ts/src/engine/card.ts
index 0d3e6be..67ebcea 100644
--- a/course/05-assemble/reference/ts/src/engine/card.ts
+++ b/course/05-assemble/reference/ts/src/engine/card.ts
@@ -36,8 +36,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
diff --git a/course/05-assemble/reference/ts/src/engine/event.ts b/course/05-assemble/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/05-assemble/reference/ts/src/engine/event.ts
+++ b/course/05-assemble/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/05-assemble/reference/ts/src/engine/index.ts b/course/05-assemble/reference/ts/src/engine/index.ts
index acf388d..cfac11c 100644
--- a/course/05-assemble/reference/ts/src/engine/index.ts
+++ b/course/05-assemble/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -34,5 +35,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/05-assemble/reference/ts/src/engine/queue.ts b/course/05-assemble/reference/ts/src/engine/queue.ts
index c177041..262b9fc 100644
--- a/course/05-assemble/reference/ts/src/engine/queue.ts
+++ b/course/05-assemble/reference/ts/src/engine/queue.ts
@@ -13,12 +13,16 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -44,6 +48,21 @@ export type Action =
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -111,10 +130,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -170,6 +186,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/05-assemble/reference/ts/src/engine/world.ts b/course/05-assemble/reference/ts/src/engine/world.ts
index 76f159a..4db63dc 100644
--- a/course/05-assemble/reference/ts/src/engine/world.ts
+++ b/course/05-assemble/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -271,11 +272,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/05-assemble/reference/ts/src/harness.ts b/course/05-assemble/reference/ts/src/harness.ts
index ab134a1..232aa4f 100644
--- a/course/05-assemble/reference/ts/src/harness.ts
+++ b/course/05-assemble/reference/ts/src/harness.ts
@@ -33,6 +33,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/06-parser/reference/rust/src/engine/aura.rs b/course/06-parser/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/06-parser/reference/rust/src/engine/aura.rs
+++ b/course/06-parser/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/06-parser/reference/rust/src/engine/card.rs b/course/06-parser/reference/rust/src/engine/card.rs
index 8171ec2..caa66ed 100644
--- a/course/06-parser/reference/rust/src/engine/card.rs
+++ b/course/06-parser/reference/rust/src/engine/card.rs
@@ -65,6 +65,23 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/06-parser/reference/rust/src/engine/event.rs b/course/06-parser/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/06-parser/reference/rust/src/engine/event.rs
+++ b/course/06-parser/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/06-parser/reference/rust/src/engine/mod.rs b/course/06-parser/reference/rust/src/engine/mod.rs
index d21d161..b6167ea 100644
--- a/course/06-parser/reference/rust/src/engine/mod.rs
+++ b/course/06-parser/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -35,9 +36,9 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/06-parser/reference/rust/src/engine/queue.rs b/course/06-parser/reference/rust/src/engine/queue.rs
index 68c9127..619fcf1 100644
--- a/course/06-parser/reference/rust/src/engine/queue.rs
+++ b/course/06-parser/reference/rust/src/engine/queue.rs
@@ -13,12 +13,16 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -77,6 +81,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -217,6 +235,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/06-parser/reference/rust/src/engine/world.rs b/course/06-parser/reference/rust/src/engine/world.rs
index a320da6..744a7fb 100644
--- a/course/06-parser/reference/rust/src/engine/world.rs
+++ b/course/06-parser/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -351,11 +352,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/06-parser/reference/rust/src/harness.rs b/course/06-parser/reference/rust/src/harness.rs
index 0081eba..4eefe4f 100644
--- a/course/06-parser/reference/rust/src/harness.rs
+++ b/course/06-parser/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/06-parser/reference/ts/src/engine/aura.ts b/course/06-parser/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/06-parser/reference/ts/src/engine/aura.ts
+++ b/course/06-parser/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/06-parser/reference/ts/src/engine/card.ts b/course/06-parser/reference/ts/src/engine/card.ts
index 0d3e6be..67ebcea 100644
--- a/course/06-parser/reference/ts/src/engine/card.ts
+++ b/course/06-parser/reference/ts/src/engine/card.ts
@@ -36,8 +36,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
diff --git a/course/06-parser/reference/ts/src/engine/event.ts b/course/06-parser/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/06-parser/reference/ts/src/engine/event.ts
+++ b/course/06-parser/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/06-parser/reference/ts/src/engine/index.ts b/course/06-parser/reference/ts/src/engine/index.ts
index 285bffb..b76f3e3 100644
--- a/course/06-parser/reference/ts/src/engine/index.ts
+++ b/course/06-parser/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -39,5 +40,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/06-parser/reference/ts/src/engine/queue.ts b/course/06-parser/reference/ts/src/engine/queue.ts
index c177041..262b9fc 100644
--- a/course/06-parser/reference/ts/src/engine/queue.ts
+++ b/course/06-parser/reference/ts/src/engine/queue.ts
@@ -13,12 +13,16 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -44,6 +48,21 @@ export type Action =
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -111,10 +130,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -170,6 +186,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/06-parser/reference/ts/src/engine/world.ts b/course/06-parser/reference/ts/src/engine/world.ts
index 76f159a..4db63dc 100644
--- a/course/06-parser/reference/ts/src/engine/world.ts
+++ b/course/06-parser/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -271,11 +272,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/06-parser/reference/ts/src/harness.ts b/course/06-parser/reference/ts/src/harness.ts
index 8d68aa6..2599acb 100644
--- a/course/06-parser/reference/ts/src/harness.ts
+++ b/course/06-parser/reference/ts/src/harness.ts
@@ -34,6 +34,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/07-rule-resolution/reference/rust/src/engine/aura.rs b/course/07-rule-resolution/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/aura.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/07-rule-resolution/reference/rust/src/engine/card.rs b/course/07-rule-resolution/reference/rust/src/engine/card.rs
index 8171ec2..caa66ed 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/card.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/card.rs
@@ -65,6 +65,23 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/07-rule-resolution/reference/rust/src/engine/event.rs b/course/07-rule-resolution/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/event.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/07-rule-resolution/reference/rust/src/engine/mod.rs b/course/07-rule-resolution/reference/rust/src/engine/mod.rs
index 21eea0d..48c2ec7 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/mod.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -36,9 +37,9 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/07-rule-resolution/reference/rust/src/engine/queue.rs b/course/07-rule-resolution/reference/rust/src/engine/queue.rs
index 68c9127..619fcf1 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/queue.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/queue.rs
@@ -13,12 +13,16 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -77,6 +81,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -217,6 +235,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/07-rule-resolution/reference/rust/src/engine/world.rs b/course/07-rule-resolution/reference/rust/src/engine/world.rs
index a320da6..744a7fb 100644
--- a/course/07-rule-resolution/reference/rust/src/engine/world.rs
+++ b/course/07-rule-resolution/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -351,11 +352,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/07-rule-resolution/reference/rust/src/harness.rs b/course/07-rule-resolution/reference/rust/src/harness.rs
index 56dae8e..217a0db 100644
--- a/course/07-rule-resolution/reference/rust/src/harness.rs
+++ b/course/07-rule-resolution/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/07-rule-resolution/reference/ts/src/engine/aura.ts b/course/07-rule-resolution/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/aura.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/07-rule-resolution/reference/ts/src/engine/card.ts b/course/07-rule-resolution/reference/ts/src/engine/card.ts
index 0d3e6be..67ebcea 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/card.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/card.ts
@@ -36,8 +36,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
diff --git a/course/07-rule-resolution/reference/ts/src/engine/event.ts b/course/07-rule-resolution/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/event.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/07-rule-resolution/reference/ts/src/engine/index.ts b/course/07-rule-resolution/reference/ts/src/engine/index.ts
index 1da69a0..25722d8 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/index.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -40,5 +41,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/07-rule-resolution/reference/ts/src/engine/queue.ts b/course/07-rule-resolution/reference/ts/src/engine/queue.ts
index c177041..262b9fc 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/queue.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/queue.ts
@@ -13,12 +13,16 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -44,6 +48,21 @@ export type Action =
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -111,10 +130,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -170,6 +186,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/07-rule-resolution/reference/ts/src/engine/world.ts b/course/07-rule-resolution/reference/ts/src/engine/world.ts
index 76f159a..4db63dc 100644
--- a/course/07-rule-resolution/reference/ts/src/engine/world.ts
+++ b/course/07-rule-resolution/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -271,11 +272,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/07-rule-resolution/reference/ts/src/harness.ts b/course/07-rule-resolution/reference/ts/src/harness.ts
index a2808ef..953401d 100644
--- a/course/07-rule-resolution/reference/ts/src/harness.ts
+++ b/course/07-rule-resolution/reference/ts/src/harness.ts
@@ -34,6 +34,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/08-push-chains/reference/rust/src/engine/aura.rs b/course/08-push-chains/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/08-push-chains/reference/rust/src/engine/aura.rs
+++ b/course/08-push-chains/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/08-push-chains/reference/rust/src/engine/card.rs b/course/08-push-chains/reference/rust/src/engine/card.rs
index 8171ec2..caa66ed 100644
--- a/course/08-push-chains/reference/rust/src/engine/card.rs
+++ b/course/08-push-chains/reference/rust/src/engine/card.rs
@@ -65,6 +65,23 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/08-push-chains/reference/rust/src/engine/event.rs b/course/08-push-chains/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/08-push-chains/reference/rust/src/engine/event.rs
+++ b/course/08-push-chains/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/08-push-chains/reference/rust/src/engine/mod.rs b/course/08-push-chains/reference/rust/src/engine/mod.rs
index be6164a..81b9403 100644
--- a/course/08-push-chains/reference/rust/src/engine/mod.rs
+++ b/course/08-push-chains/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -37,9 +38,9 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/08-push-chains/reference/rust/src/engine/queue.rs b/course/08-push-chains/reference/rust/src/engine/queue.rs
index 68c9127..619fcf1 100644
--- a/course/08-push-chains/reference/rust/src/engine/queue.rs
+++ b/course/08-push-chains/reference/rust/src/engine/queue.rs
@@ -13,12 +13,16 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -77,6 +81,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -217,6 +235,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/08-push-chains/reference/rust/src/engine/world.rs b/course/08-push-chains/reference/rust/src/engine/world.rs
index a320da6..744a7fb 100644
--- a/course/08-push-chains/reference/rust/src/engine/world.rs
+++ b/course/08-push-chains/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -351,11 +352,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/08-push-chains/reference/rust/src/harness.rs b/course/08-push-chains/reference/rust/src/harness.rs
index 3abc8f4..51957e6 100644
--- a/course/08-push-chains/reference/rust/src/harness.rs
+++ b/course/08-push-chains/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/08-push-chains/reference/ts/src/engine/aura.ts b/course/08-push-chains/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/08-push-chains/reference/ts/src/engine/aura.ts
+++ b/course/08-push-chains/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/08-push-chains/reference/ts/src/engine/card.ts b/course/08-push-chains/reference/ts/src/engine/card.ts
index 0d3e6be..67ebcea 100644
--- a/course/08-push-chains/reference/ts/src/engine/card.ts
+++ b/course/08-push-chains/reference/ts/src/engine/card.ts
@@ -36,8 +36,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
diff --git a/course/08-push-chains/reference/ts/src/engine/event.ts b/course/08-push-chains/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/08-push-chains/reference/ts/src/engine/event.ts
+++ b/course/08-push-chains/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/08-push-chains/reference/ts/src/engine/index.ts b/course/08-push-chains/reference/ts/src/engine/index.ts
index 7e86785..26c9a94 100644
--- a/course/08-push-chains/reference/ts/src/engine/index.ts
+++ b/course/08-push-chains/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -41,5 +42,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/08-push-chains/reference/ts/src/engine/queue.ts b/course/08-push-chains/reference/ts/src/engine/queue.ts
index c177041..262b9fc 100644
--- a/course/08-push-chains/reference/ts/src/engine/queue.ts
+++ b/course/08-push-chains/reference/ts/src/engine/queue.ts
@@ -13,12 +13,16 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -44,6 +48,21 @@ export type Action =
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -111,10 +130,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -170,6 +186,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/08-push-chains/reference/ts/src/engine/world.ts b/course/08-push-chains/reference/ts/src/engine/world.ts
index 76f159a..4db63dc 100644
--- a/course/08-push-chains/reference/ts/src/engine/world.ts
+++ b/course/08-push-chains/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -271,11 +272,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/08-push-chains/reference/ts/src/harness.ts b/course/08-push-chains/reference/ts/src/harness.ts
index a9b0bf9..866d225 100644
--- a/course/08-push-chains/reference/ts/src/harness.ts
+++ b/course/08-push-chains/reference/ts/src/harness.ts
@@ -34,6 +34,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/aura.rs b/course/09-turn-pipeline/reference/rust/src/engine/aura.rs
index 0656db4..8d48c52 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/aura.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,7 +13,7 @@
//! Summoning a minion also runs an Aura Update.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in play.
pub fn aura_update_other(world: &mut World) {
@@ -21,6 +22,7 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: id,
aura,
});
}
@@ -28,6 +30,17 @@ pub fn aura_update_other(world: &mut World) {
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .map(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
@@ -40,7 +53,10 @@ pub fn activations(world: &World, owner: PlayerId, on: TriggerOn) -> u32 {
trigger,
times,
stacking,
- } = active.aura;
+ } = active.aura
+ else {
+ continue;
+ };
if active.owner != owner || trigger != on {
continue;
}
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/card.rs b/course/09-turn-pipeline/reference/rust/src/engine/card.rs
index 8171ec2..caa66ed 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/card.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/card.rs
@@ -65,6 +65,23 @@ pub enum Aura {
times: u32,
stacking: Stacking,
},
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/event.rs b/course/09-turn-pipeline/reference/rust/src/engine/event.rs
index 8c1b0a8..b79cc66 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/event.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/mod.rs b/course/09-turn-pipeline/reference/rust/src/engine/mod.rs
index be6164a..81b9403 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/mod.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -37,9 +38,9 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx};
+pub use queue::{run_sequence, summon, Action, Ctx, DamageStep, DAMAGE_STEPS};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs
index 68c9127..619fcf1 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/queue.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/queue.rs
@@ -13,12 +13,16 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -77,6 +81,21 @@ pub enum Action {
},
}
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -146,8 +165,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -217,6 +235,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/09-turn-pipeline/reference/rust/src/engine/world.rs b/course/09-turn-pipeline/reference/rust/src/engine/world.rs
index a320da6..744a7fb 100644
--- a/course/09-turn-pipeline/reference/rust/src/engine/world.rs
+++ b/course/09-turn-pipeline/reference/rust/src/engine/world.rs
@@ -34,10 +34,11 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: EntityId,
pub aura: Aura,
}
@@ -351,11 +352,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/09-turn-pipeline/reference/rust/src/harness.rs b/course/09-turn-pipeline/reference/rust/src/harness.rs
index 02ab7e9..c0503ce 100644
--- a/course/09-turn-pipeline/reference/rust/src/harness.rs
+++ b/course/09-turn-pipeline/reference/rust/src/harness.rs
@@ -25,6 +25,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -43,6 +44,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -73,6 +75,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/aura.ts b/course/09-turn-pipeline/reference/ts/src/engine/aura.ts
index ba66ce5..bae562f 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/aura.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -12,17 +13,26 @@
// Summoning a minion also runs an Aura Update.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
export function auraUpdateOther(world: World): void {
world.setActiveAuras(
world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -32,9 +42,10 @@ export function auraUpdateOther(world: World): void {
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
let max = 1;
let extra = 0;
- for (const active of world.activeAuras()) {
- const { trigger, times, stacking } = active.aura;
- if (active.owner !== owner || trigger !== on) continue;
+ for (const { owner: auraOwner, aura } of world.activeAuras()) {
+ if (aura.type !== "triggerMultiplier") continue;
+ const { trigger, times, stacking } = aura;
+ if (auraOwner !== owner || trigger !== on) continue;
if (stacking === "max") max = Math.max(max, times);
else extra += times - 1;
}
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/card.ts b/course/09-turn-pipeline/reference/ts/src/engine/card.ts
index 0d3e6be..67ebcea 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/card.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/card.ts
@@ -36,8 +36,13 @@ export function triggerLabel(on: TriggerOn): string {
* the aura owner's owner, activate `times` times each. `stacking` says how several
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
* a doubler still double); `"additive"` adds each copy's extra activations.
+ *
+ * `damageRedirect`: damage that would be dealt to the aura owner's hero is dealt to
+ * the aura's minion instead (see `DAMAGE_STEPS` in `queue.ts`).
*/
-export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
+export type Aura =
+ | { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" };
/** Who an effect touches. */
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/event.ts b/course/09-turn-pipeline/reference/ts/src/engine/event.ts
index d25fbbc..a4c32bd 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/event.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/index.ts b/course/09-turn-pipeline/reference/ts/src/engine/index.ts
index 7e86785..26c9a94 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/index.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -41,5 +42,5 @@ export {
type TriggerOn,
} from "./card";
export type { Event } from "./event";
-export { Ctx, runSequence, summon, type Action } from "./queue";
+export { Ctx, DAMAGE_STEPS, runSequence, summon, type Action, type DamageStep } from "./queue";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts
index c177041..262b9fc 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/queue.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/queue.ts
@@ -13,12 +13,16 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -44,6 +48,21 @@ export type Action =
/** Give one entity +attack/+health. */
| { type: "buff"; id: EntityId; attack: number; health: number };
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -111,10 +130,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -170,6 +186,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/09-turn-pipeline/reference/ts/src/engine/world.ts b/course/09-turn-pipeline/reference/ts/src/engine/world.ts
index 76f159a..4db63dc 100644
--- a/course/09-turn-pipeline/reference/ts/src/engine/world.ts
+++ b/course/09-turn-pipeline/reference/ts/src/engine/world.ts
@@ -25,9 +25,10 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/** An aura in force: which minion has it, and that minion's owner. */
export interface ActiveAura {
owner: PlayerId;
+ source: EntityId;
aura: Aura;
}
@@ -271,11 +272,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/09-turn-pipeline/reference/ts/src/harness.ts b/course/09-turn-pipeline/reference/ts/src/harness.ts
index 1c562e2..fd54ea3 100644
--- a/course/09-turn-pipeline/reference/ts/src/harness.ts
+++ b/course/09-turn-pipeline/reference/ts/src/harness.ts
@@ -34,6 +34,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/10-spellgrid/reference/rust/src/engine/aura.rs b/course/10-spellgrid/reference/rust/src/engine/aura.rs
index 37eb4c8..1df46dc 100644
--- a/course/10-spellgrid/reference/rust/src/engine/aura.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/aura.rs
@@ -1,4 +1,5 @@
-//! Auras, and the one routine that applies trigger multipliers.
+//! Auras, the one routine that applies trigger multipliers, and the lookup for
+//! damage redirects.
//!
//! An aura is data on a card: a continuous effect its minion has while in play.
//! The engine never asks "is that card on the board?" in the middle of resolving
@@ -18,7 +19,7 @@
//! limits the total, whatever multiplies it.
use super::card::{Aura, Stacking, TriggerOn};
-use super::world::{ActiveAura, PlayerId, World};
+use super::world::{ActiveAura, EntityId, PlayerId, World};
/// Aura Update (Other): replace the snapshot with the auras of every minion now in
/// play, and the auras from outside the cards, which apply to both players.
@@ -28,18 +29,34 @@ pub fn aura_update_other(world: &mut World) {
for &aura in &world.card(world.card_name(id)).auras {
active.push(ActiveAura {
owner: world.owner_of(id),
+ source: Some(id),
aura,
});
}
}
for &aura in world.rule_auras() {
for owner in 0..2 {
- active.push(ActiveAura { owner, aura });
+ active.push(ActiveAura {
+ owner,
+ source: None,
+ aura,
+ });
}
}
world.set_active_auras(active);
}
+/// The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+/// the oldest (order of play) of `player`'s minions with a `DamageRedirect` aura in the
+/// snapshot. The snapshot lists minions oldest first, so that is the first one found.
+pub fn redirect_for(world: &World, player: PlayerId) -> Option {
+ world
+ .active_auras()
+ .iter()
+ .find(|a| matches!(a.aura, Aura::DamageRedirect { .. }) && a.owner == player)
+ .and_then(|a| a.source)
+}
+
/// How many times a trigger listening for `on`, on a minion `owner` controls, activates
/// when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
/// force the answer is 1. Multipliers with `Stacking::Max` contribute their largest
diff --git a/course/10-spellgrid/reference/rust/src/engine/card.rs b/course/10-spellgrid/reference/rust/src/engine/card.rs
index 6aeec1b..d4a88d9 100644
--- a/course/10-spellgrid/reference/rust/src/engine/card.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/card.rs
@@ -69,6 +69,23 @@ pub enum Aura {
/// whatever multiplies them. Not in the card format: SpellGrid compiles it from a
/// negated rule (`DEATHRATTLE IS NOT DOUBLE`).
TriggerCap { trigger: TriggerOn, times: u32 },
+ /// Damage that would be dealt to the aura owner's hero is dealt to the aura's
+ /// minion instead (see `DAMAGE_STEPS` in `queue.rs`).
+ DamageRedirect { from: RedirectFrom, to: RedirectTo },
+}
+
+/// Whose damage a redirect takes: the card format's `"friendlyHero"`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RedirectFrom {
+ FriendlyHero,
+}
+
+/// Who takes redirected damage. `This` is the card format's `"self"`: the aura's minion.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+pub enum RedirectTo {
+ #[serde(rename = "self")]
+ This,
}
/// Who an effect touches. `This` is the schema's `"self"`.
diff --git a/course/10-spellgrid/reference/rust/src/engine/event.rs b/course/10-spellgrid/reference/rust/src/engine/event.rs
index 115f636..7b05aa4 100644
--- a/course/10-spellgrid/reference/rust/src/engine/event.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/event.rs
@@ -28,6 +28,12 @@ pub enum Event {
player: PlayerId,
amount: i32,
},
+ /// Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows
+ /// shows the full amount.
+ ArmorLost {
+ player: PlayerId,
+ amount: i32,
+ },
StatsChanged {
id: EntityId,
attack: i32,
diff --git a/course/10-spellgrid/reference/rust/src/engine/mod.rs b/course/10-spellgrid/reference/rust/src/engine/mod.rs
index 4099972..9d8c21d 100644
--- a/course/10-spellgrid/reference/rust/src/engine/mod.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/mod.rs
@@ -13,7 +13,8 @@
//!
//! Module 4: auras as card data, Aura Updates at defined points, and one
//! routine that decides how many times a queued trigger activates. Playing a
-//! minion from hand, and Battlecries.
+//! minion from hand, and Battlecries. A second interceptor, replacement: the
+//! steps that rewrite a Damage before it lands (a redirect, then Armor).
//!
//! Module 5: behaviour as data. Cards are definitions in the course's card format,
//! loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -46,10 +47,12 @@ mod world;
pub use assemble::{assemble, register_assembled, AssembleError, Half};
pub use aura::aura_update_other;
pub use card::{
- Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, Stacking, Target,
- Trigger, TriggerOn,
+ Aura, Card, CardDef, CardLibrary, CardType, Clause, Effect, PoolFilter, RedirectFrom,
+ RedirectTo, Stacking, Target, Trigger, TriggerOn,
};
pub use event::Event;
-pub use queue::{run_sequence, summon, Action, Ctx, EndOfPhaseStep, END_OF_PHASE};
+pub use queue::{
+ run_sequence, summon, Action, Ctx, DamageStep, EndOfPhaseStep, DAMAGE_STEPS, END_OF_PHASE,
+};
pub use spellgrid::{rule_auras, Multiplier, SeamWords, SpellGrid};
pub use world::{Chooser, EntityId, PlayerId, World, Zone, HERO_HEALTH};
diff --git a/course/10-spellgrid/reference/rust/src/engine/queue.rs b/course/10-spellgrid/reference/rust/src/engine/queue.rs
index b498f4f..75270ee 100644
--- a/course/10-spellgrid/reference/rust/src/engine/queue.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/queue.rs
@@ -13,6 +13,10 @@
//! decided by one routine, `activations` in `aura.rs`, from aura data on cards.
//! Nothing here knows which cards multiply which triggers.
//!
+//! Module 4, second interceptor: replacement. Before a Damage changes the world,
+//! the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+//! who takes it, then Armor takes what it can of damage to a hero.
+//!
//! Module 5: a card's effects are data. Each one is interpreted (`effect.rs`) and
//! its actions performed before the next effect of the same ability is read, so
//! "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
@@ -22,7 +26,7 @@
//! the same Sequence with the list reversed, to show what the order decides.
use super::assemble::assemble_from_pool;
-use super::aura::{activations, aura_update_other};
+use super::aura::{activations, aura_update_other, redirect_for};
use super::card::{Effect, PoolFilter, TriggerOn};
use super::death::death_creation_step;
use super::effect::actions;
@@ -101,6 +105,21 @@ pub enum EndOfPhaseStep {
pub const END_OF_PHASE: [EndOfPhaseStep; 2] =
[EndOfPhaseStep::DeathCreation, EndOfPhaseStep::AuraUpdate];
+/// A step that may rewrite one Damage to one target before the world changes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DamageStep {
+ /// Damage to a hero whose owner has a `DamageRedirect` aura in force goes to that
+ /// aura's minion instead (the oldest one's, if several).
+ Redirect,
+ /// Damage to a hero lowers its owner's Armor first, then Health.
+ Armor,
+}
+
+/// The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+/// never offered to a redirect again. A redirect comes first: Armor protects only
+/// the hero that actually takes the damage.
+pub const DAMAGE_STEPS: [DamageStep; 2] = [DamageStep::Redirect, DamageStep::Armor];
+
/// What an effect may look at when it runs.
pub struct Ctx<'a> {
pub world: &'a World,
@@ -174,8 +193,7 @@ fn perform(world: &mut World, action: Action) {
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 });
+ events.push(apply_damage(world, target, amount));
}
react(world, &events);
}
@@ -246,6 +264,36 @@ fn run_effects(
}
}
+/// Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+/// Returns its Damage Event, which names whoever took it.
+fn apply_damage(world: &mut World, target: EntityId, amount: i32) -> Event {
+ let mut taker = target;
+ let mut absorbed = 0;
+ for step in DAMAGE_STEPS {
+ match step {
+ DamageStep::Redirect => {
+ if world.is_hero(taker) {
+ taker = redirect_for(world, world.owner_of(taker)).unwrap_or(taker);
+ }
+ }
+ DamageStep::Armor => {
+ if world.is_hero(taker) {
+ let player = world.owner_of(taker);
+ absorbed = world.armor(player).min(amount);
+ if absorbed > 0 {
+ world.lose_armor(player, absorbed);
+ }
+ }
+ }
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ Event::Damaged {
+ target: taker,
+ amount,
+ }
+}
+
/// A Phase: queue every trigger that responds to `events`, then resolve them one by one.
fn react(world: &mut World, events: &[Event]) {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/10-spellgrid/reference/rust/src/engine/world.rs b/course/10-spellgrid/reference/rust/src/engine/world.rs
index d575e07..75fd0b1 100644
--- a/course/10-spellgrid/reference/rust/src/engine/world.rs
+++ b/course/10-spellgrid/reference/rust/src/engine/world.rs
@@ -35,10 +35,13 @@ struct Entity {
pending_destroy: bool,
}
-/// An aura in force: which player's minion has it.
+/// An aura in force: which minion has it, and that minion's owner. An aura from outside
+/// the cards (Module 10) has no minion (`source` is `None`), and is in force once for
+/// each player.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActiveAura {
pub owner: PlayerId,
+ pub source: Option,
pub aura: Aura,
}
@@ -384,11 +387,19 @@ impl World {
self.depths.clear();
}
- pub fn damage(&mut self, target: EntityId, amount: i32) {
- self.entity_mut(target).health -= amount;
+ /// `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ /// (see `lose_armor`): Health drops by the rest. The event shows the full amount.
+ pub fn damage(&mut self, target: EntityId, amount: i32, absorbed: i32) {
+ self.entity_mut(target).health -= amount - absorbed;
self.record(Event::Damaged { target, amount });
}
+ /// `player`'s hero's Armor absorbs `amount` damage.
+ pub fn lose_armor(&mut self, player: PlayerId, amount: i32) {
+ self.sides[player as usize].armor -= amount;
+ self.record(Event::ArmorLost { player, amount });
+ }
+
/// Draw the top card of `player`'s deck (the first in the list). An empty deck
/// records `Fatigue` and changes nothing else.
pub fn draw(&mut self, player: PlayerId) {
diff --git a/course/10-spellgrid/reference/rust/src/harness.rs b/course/10-spellgrid/reference/rust/src/harness.rs
index 08d4df4..9c9ec48 100644
--- a/course/10-spellgrid/reference/rust/src/harness.rs
+++ b/course/10-spellgrid/reference/rust/src/harness.rs
@@ -28,6 +28,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 },
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
@@ -46,6 +47,7 @@ impl GameEvent {
Event::Drew { player, id } => GameEvent::Drew { player, id },
Event::Fatigue { player } => GameEvent::Fatigue { player },
Event::ArmorGained { player, amount } => GameEvent::ArmorGained { player, amount },
+ Event::ArmorLost { player, amount } => GameEvent::ArmorLost { player, amount },
Event::StatsChanged { id, attack, health } => {
GameEvent::StatsChanged { id, attack, health }
}
@@ -78,6 +80,9 @@ impl std::fmt::Display for GameEvent {
GameEvent::ArmorGained { player, amount } => {
write!(f, "ArmorGained{{player: {player}, amount: {amount}}}")
}
+ GameEvent::ArmorLost { player, amount } => {
+ write!(f, "ArmorLost{{player: {player}, amount: {amount}}}")
+ }
GameEvent::StatsChanged { id, attack, health } => {
write!(
f,
diff --git a/course/10-spellgrid/reference/ts/src/engine/aura.ts b/course/10-spellgrid/reference/ts/src/engine/aura.ts
index 050dbcc..0b58203 100644
--- a/course/10-spellgrid/reference/ts/src/engine/aura.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/aura.ts
@@ -1,4 +1,5 @@
-// Auras, and the one routine that applies trigger multipliers.
+// Auras, the one routine that applies trigger multipliers, and the lookup for
+// damage redirects.
//
// An aura is data on a card: a continuous effect its minion has while in play.
// The engine never asks "is that card on the board?" in the middle of resolving
@@ -18,7 +19,7 @@
// limits the total, whatever multiplies it.
import type { TriggerOn } from "./card";
-import type { PlayerId, World } from "./world";
+import type { EntityId, PlayerId, World } from "./world";
/**
* Aura Update (Other): replace the snapshot with the auras of every minion now in
@@ -28,12 +29,21 @@ export function auraUpdateOther(world: World): void {
const players: PlayerId[] = [0, 1];
world.setActiveAuras([
...world.minionsInPlay().flatMap((id) =>
- world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
+ world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), source: id, aura })),
),
...world.ruleAuras().flatMap((aura) => players.map((owner) => ({ owner, aura }))),
]);
}
+/**
+ * The minion that takes damage meant for `player`'s hero, if a redirect is in force:
+ * the oldest (order of play) of `player`'s minions with a `damageRedirect` aura in the
+ * snapshot. The snapshot lists minions oldest first, so that is the first one found.
+ */
+export function redirectFor(world: World, player: PlayerId): EntityId | undefined {
+ return world.activeAuras().find((a) => a.aura.type === "damageRedirect" && a.owner === player)?.source;
+}
+
/**
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
@@ -46,7 +56,7 @@ export function activations(world: World, owner: PlayerId, on: TriggerOn): numbe
let extra = 0;
let cap = Infinity;
for (const { owner: auraOwner, aura } of world.activeAuras()) {
- if (auraOwner !== owner || aura.trigger !== on) continue;
+ if (aura.type === "damageRedirect" || auraOwner !== owner || aura.trigger !== on) continue;
if (aura.type === "triggerCap") cap = Math.min(cap, aura.times);
else if (aura.stacking === "max") max = Math.max(max, aura.times);
else extra += aura.times - 1;
diff --git a/course/10-spellgrid/reference/ts/src/engine/card.ts b/course/10-spellgrid/reference/ts/src/engine/card.ts
index f12bd61..3a83e0c 100644
--- a/course/10-spellgrid/reference/ts/src/engine/card.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/card.ts
@@ -39,6 +39,11 @@ export function triggerLabel(on: TriggerOn): string {
*/
export type Aura =
| { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" }
+ /**
+ * Damage that would be dealt to the aura owner's hero is dealt to the aura's minion
+ * instead (see `DAMAGE_STEPS` in `queue.ts`).
+ */
+ | { type: "damageRedirect"; from: "friendlyHero"; to: "self" }
/**
* Module 10: triggers listening for `trigger` activate at most `times` times, whatever
* multiplies them. Not in the card format: SpellGrid compiles it from a negated rule
diff --git a/course/10-spellgrid/reference/ts/src/engine/event.ts b/course/10-spellgrid/reference/ts/src/engine/event.ts
index 58a4509..48f3c0c 100644
--- a/course/10-spellgrid/reference/ts/src/engine/event.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/event.ts
@@ -11,6 +11,8 @@ export type Event =
| { type: "Drew"; player: PlayerId; id: EntityId }
| { type: "Fatigue"; player: PlayerId }
| { type: "ArmorGained"; player: PlayerId; amount: number }
+ /** Armor absorbed `amount` of a Damage to `player`'s hero. The `Damaged` that follows shows the full amount. */
+ | { type: "ArmorLost"; player: PlayerId; amount: number }
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
| { type: "SpellCast"; card: string }
/**
diff --git a/course/10-spellgrid/reference/ts/src/engine/index.ts b/course/10-spellgrid/reference/ts/src/engine/index.ts
index d052a0f..2590dd7 100644
--- a/course/10-spellgrid/reference/ts/src/engine/index.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/index.ts
@@ -13,7 +13,8 @@
//
// Module 4: auras as card data, Aura Updates at defined points, and one
// routine that decides how many times a queued trigger activates. Playing a
-// minion from hand, and Battlecries.
+// minion from hand, and Battlecries. A second interceptor, replacement: the
+// steps that rewrite a Damage before it lands (a redirect, then Armor).
//
// Module 5: behaviour as data. Cards are definitions in the course's card format,
// loaded from JSON; one interpreter turns their effects into actions. Assemble
@@ -48,6 +49,15 @@ export {
} from "./card";
export type { Event } from "./event";
export { auraUpdateOther } from "./aura";
-export { Ctx, END_OF_PHASE, runSequence, summon, type Action, type EndOfPhaseStep } from "./queue";
+export {
+ Ctx,
+ DAMAGE_STEPS,
+ END_OF_PHASE,
+ runSequence,
+ summon,
+ type Action,
+ type DamageStep,
+ type EndOfPhaseStep,
+} from "./queue";
export { ruleAuras, SpellGrid, type SeamWords } from "./spellgrid";
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
diff --git a/course/10-spellgrid/reference/ts/src/engine/queue.ts b/course/10-spellgrid/reference/ts/src/engine/queue.ts
index 989e0b9..cc230fe 100644
--- a/course/10-spellgrid/reference/ts/src/engine/queue.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/queue.ts
@@ -13,6 +13,10 @@
// decided by one routine, `activations` in `aura.ts`, from aura data on cards.
// Nothing here knows which cards multiply which triggers.
//
+// Module 4, second interceptor: replacement. Before a Damage changes the world,
+// the steps in `DAMAGE_STEPS` may rewrite it, in that order: a redirect changes
+// who takes it, then Armor takes what it can of damage to a hero.
+//
// Module 5: a card's effects are data. Each one is interpreted (`effect.ts`) and
// its actions performed before the next effect of the same ability is read, so
// "Assemble a minion. Give all minions in your hand +2/+2." buffs the new minion.
@@ -22,7 +26,7 @@
// the same Sequence with the list reversed, to show what the order decides.
import { assembleFromPool } from "./assemble";
-import { activations, auraUpdateOther } from "./aura";
+import { activations, auraUpdateOther, redirectFor } from "./aura";
import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import { actions } from "./effect";
@@ -64,6 +68,21 @@ export type EndOfPhaseStep = "deathCreation" | "auraUpdate";
*/
export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"];
+/**
+ * A step that may rewrite one Damage to one target before the world changes:
+ * - `"redirect"`: damage to a hero whose owner has a `damageRedirect` aura in force
+ * goes to that aura's minion instead (the oldest one's, if several);
+ * - `"armor"`: damage to a hero lowers its owner's Armor first, then Health.
+ */
+export type DamageStep = "redirect" | "armor";
+
+/**
+ * The order the steps run in. Each runs once per Damage, so a rewritten Damage is
+ * never offered to a redirect again. A redirect comes first: Armor protects only
+ * the hero that actually takes the damage.
+ */
+export const DAMAGE_STEPS: readonly DamageStep[] = ["redirect", "armor"];
+
/** What an effect may look at when it runs. */
export class Ctx {
constructor(
@@ -139,10 +158,7 @@ function perform(world: World, action: Action): void {
// 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 });
- }
+ for (const target of targets) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
@@ -201,6 +217,31 @@ function runEffects(world: World, effects: Effect[], ctx: Ctx): void {
}
}
+/**
+ * Deal one Damage to one target, after the steps of `DAMAGE_STEPS` have rewritten it.
+ * Returns its Damage Event, which names whoever took it.
+ */
+function applyDamage(world: World, target: EntityId, amount: number): Event {
+ let taker = target;
+ let absorbed = 0;
+ for (const step of DAMAGE_STEPS) {
+ switch (step) {
+ case "redirect":
+ if (world.isHero(taker)) taker = redirectFor(world, world.ownerOf(taker)) ?? taker;
+ break;
+ case "armor":
+ if (world.isHero(taker)) {
+ const player = world.ownerOf(taker);
+ absorbed = Math.min(world.armor(player), amount);
+ if (absorbed > 0) world.loseArmor(player, absorbed);
+ }
+ break;
+ }
+ }
+ world.damage(taker, amount, absorbed);
+ return { type: "Damaged", target: taker, amount };
+}
+
/** A Phase: queue every trigger that responds to `events`, then resolve them one by one. */
function react(world: World, events: Event[]): void {
// Fill the Queue: for each event (already in order of play), each listener in order of play.
diff --git a/course/10-spellgrid/reference/ts/src/engine/world.ts b/course/10-spellgrid/reference/ts/src/engine/world.ts
index eef387c..65ded00 100644
--- a/course/10-spellgrid/reference/ts/src/engine/world.ts
+++ b/course/10-spellgrid/reference/ts/src/engine/world.ts
@@ -26,9 +26,13 @@ interface Entity {
pendingDestroy: boolean;
}
-/** An aura in force: which player's minion has it. */
+/**
+ * An aura in force: which minion has it, and that minion's owner. An aura from outside
+ * the cards (Module 10) has no minion, and is in force once for each player.
+ */
export interface ActiveAura {
owner: PlayerId;
+ source?: EntityId;
aura: Aura;
}
@@ -304,11 +308,21 @@ export class World {
this.depths = [];
}
- damage(target: EntityId, amount: number): void {
- this.entity(target).health -= amount;
+ /**
+ * `target` takes `amount` damage, of which `absorbed` was already taken by Armor
+ * (see `loseArmor`): Health drops by the rest. The event shows the full amount.
+ */
+ damage(target: EntityId, amount: number, absorbed = 0): void {
+ this.entity(target).health -= amount - absorbed;
this.record({ type: "Damaged", target, amount });
}
+ /** `player`'s hero's Armor absorbs `amount` damage. */
+ loseArmor(player: PlayerId, amount: number): void {
+ this.sides[player].armor -= amount;
+ this.record({ type: "ArmorLost", player, amount });
+ }
+
/**
* Draw the top card of `player`'s deck (the first in the list). An empty deck
* records `Fatigue` and changes nothing else.
diff --git a/course/10-spellgrid/reference/ts/src/harness.ts b/course/10-spellgrid/reference/ts/src/harness.ts
index 853ecaa..771ed8a 100644
--- a/course/10-spellgrid/reference/ts/src/harness.ts
+++ b/course/10-spellgrid/reference/ts/src/harness.ts
@@ -36,6 +36,7 @@ export type GameEvent =
| { type: "Drew"; player: Player; id: Id }
| { type: "Fatigue"; player: Player }
| { type: "ArmorGained"; player: Player; amount: number }
+ | { type: "ArmorLost"; player: Player; amount: number }
| { type: "StatsChanged"; id: Id; attack: number; health: number }
| { type: "SpellCast"; card: string };
diff --git a/course/cards/bolf-ramshield.json b/course/cards/bolf-ramshield.json
new file mode 100644
index 0000000..b7e080e
--- /dev/null
+++ b/course/cards/bolf-ramshield.json
@@ -0,0 +1,18 @@
+{
+ "name": "Bolf Ramshield",
+ "type": "minion",
+ "cost": 6,
+ "attack": 3,
+ "health": 9,
+ "split": 0,
+ "text": [
+ {
+ "kind": "aura",
+ "modifier": {
+ "type": "damageRedirect",
+ "from": "friendlyHero",
+ "to": "self"
+ }
+ }
+ ]
+}
diff --git a/course/catalog.json b/course/catalog.json
index 848fbc1..9036a9b 100644
--- a/course/catalog.json
+++ b/course/catalog.json
@@ -49,6 +49,16 @@
"text": "Your minions trigger their Deathrattles twice.",
"source": "https://hearthstone.wiki.gg/wiki/Baron_Rivendare"
},
+ {
+ "name": "Bolf Ramshield",
+ "kind": "card",
+ "type": "minion",
+ "cost": 6,
+ "attack": 3,
+ "health": 9,
+ "text": "Whenever your hero takes damage, this minion takes it instead.",
+ "source": "https://hearthstone.wiki.gg/wiki/Bolf_Ramshield"
+ },
{
"name": "Twisting Nether",
"kind": "card",
diff --git a/course/harness.md b/course/harness.md
index 5fca2a7..31b5419 100644
--- a/course/harness.md
+++ b/course/harness.md
@@ -11,7 +11,7 @@ Each module adds functions; `start-module` prints the line for the module you st
- M1: `newGame(opts?: { decks?: [string[], string[]] }): Game`; `summon(card: string, player?: 0|1): Id` (to right end of board); `addToHand(card, player?)`, `addToDeck(card, player?)`; `play(handId: Id): void` (Expansion Drop; required from M4); `zoneOf(id): "deck"|"hand"|"board"|"graveyard"`; `board(player): Id[]` (left to right); `hand(player): Id[]`; `orderOfPlay(id): number`; `nameOf(id): string`.
- M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`; `logTree(): string` (the log as an indented tree, see below).
- M3: `destroy(target: Id): void` (marks pending-destroy, one Sequence).
-- M4: no new functions; `play(handId)` (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (`Summoned{id}`), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). The M4 tests also use `Baron Rivendare`, `Novice Engineer` and `Twisting Nether` (the M3 Expansion Drop card).
+- M4: no new functions; `play(handId)` (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (`Summoned{id}`), then its Battlecry resolves (`Triggered{source, trigger: "battlecry"}`). The M4 tests also use `Baron Rivendare`, `Novice Engineer`, `Bolf Ramshield` and `Twisting Nether` (the M3 Expansion Drop card). New event `ArmorLost{player, amount}` (see `GameEvent`).
- M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `" (Assembled)"`; throws `AssembleError` if text unchanged); `cost(id): number`; `setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void` (makes Assemble's choices). `CardDef` is the card format of `course/schema/card.schema.json`; `newGame` loads every card file in the workspace's `cards/` directory.
### Side by side (Part I)
@@ -44,7 +44,9 @@ From Module 5, Rust's `CardDef` must implement `serde::Deserialize` for the card
### `GameEvent`
-Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, trigger}` (`trigger`: `"deathrattle"|"battlecry"|"damaged"|...`), `Destroyed{id}` (removed in a Death Creation Step), `DeathPhase{round}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `StatsChanged{id, attack, health}`, `SpellCast{card}`.
+Tagged by `type`: `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, trigger}` (`trigger`: `"deathrattle"|"battlecry"|"damaged"|...`), `Destroyed{id}` (removed in a Death Creation Step), `DeathPhase{round}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `ArmorLost{player, amount}` (from M4), `StatsChanged{id, attack, health}`, `SpellCast{card}`.
+
+From M4, damage to a hero lowers its owner's Armor first, then Health. When Armor absorbs any, `ArmorLost{player, amount}` (the amount Armor took) is logged just before the `Damaged` event, and `Damaged.amount` is still the full amount. A damage redirect (M4) changes the target before that: the `Damaged` event names the minion that took the damage.
Drawing from an empty deck logs `Fatigue{player}` and does nothing else; the engine must not panic.
@@ -62,6 +64,7 @@ pub enum GameEvent {
Drew { player: Player, id: Id },
Fatigue { player: Player },
ArmorGained { player: Player, amount: i32 },
+ ArmorLost { player: Player, amount: i32 }, // from M4
StatsChanged { id: Id, attack: i32, health: i32 },
SpellCast { card: String },
}
diff --git a/course/schema/card.schema.json b/course/schema/card.schema.json
index f171b4a..a0b576c 100644
--- a/course/schema/card.schema.json
+++ b/course/schema/card.schema.json
@@ -1,5 +1,5 @@
{
- "$comment": "Structured card text. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. split divides top (text[0:split]) from bottom (text[split:]). Effect assemble: put a minion into your hand, chosen from the pool of minions matching filter (filter.trigger: it has a trigger clause with that on), with half its text replaced (Module 5). Effect buffHand: give every minion in your hand +attack/+health.",
+ "$comment": "Structured card text. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. split divides top (text[0:split]) from bottom (text[split:]). Effect assemble: put a minion into your hand, chosen from the pool of minions matching filter (filter.trigger: it has a trigger clause with that on), with half its text replaced (Module 5). Effect buffHand: give every minion in your hand +attack/+health. Aura damageRedirect: damage that would be dealt to your hero is dealt to this minion instead (Module 4).",
"type": "object",
"required": ["name", "type", "cost", "text", "split"],
"properties": {
@@ -22,11 +22,17 @@
"on": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] } } },
{ "type": "object", "required": ["kind", "modifier"], "properties": {
"kind": { "const": "aura" },
- "modifier": { "type": "object", "required": ["type", "trigger", "times", "stacking"], "properties": {
- "type": { "const": "triggerMultiplier" },
- "trigger": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] },
- "times": { "type": "integer", "minimum": 2 },
- "stacking": { "enum": ["max", "additive"] } } } } },
+ "modifier": { "oneOf": [
+ { "type": "object", "required": ["type", "trigger", "times", "stacking"], "properties": {
+ "type": { "const": "triggerMultiplier" },
+ "trigger": { "enum": ["deathrattle", "battlecry", "selfDamaged", "friendlyMinionDamaged", "anyMinionDamaged", "heroDamaged", "endOfTurn"] },
+ "times": { "type": "integer", "minimum": 2 },
+ "stacking": { "enum": ["max", "additive"] } } },
+ { "type": "object", "required": ["type", "from", "to"], "properties": {
+ "type": { "const": "damageRedirect" },
+ "from": { "const": "friendlyHero" },
+ "to": { "const": "self" } } }
+ ] } } },
{ "type": "object", "required": ["kind", "effect"], "properties": {
"kind": { "const": "effect" },
"effect": { "oneOf": [
diff --git a/docs/research/hearthstone.md b/docs/research/hearthstone.md
index fbe49fc..ab53383 100644
--- a/docs/research/hearthstone.md
+++ b/docs/research/hearthstone.md
@@ -249,3 +249,25 @@ No.
- No source I read defines a half as "trigger" vs "effect". Do not make that claim until it is verified, for example from the reddit threads (which could not be loaded) or from in-game testing.
The Patashu reddit post and both Assemble reddit threads remain unread. Any course claim attributed to them needs a manual check.
+
+## Source 5: Bolf Ramshield and Armor (fetched 2026-11-11, course v2 Task 3)
+
+Fetched with WebFetch, which returns quotes of 125 characters or fewer, so the quotes below are short verbatim excerpts.
+
+| Source | Result |
+|---|---|
+| https://hearthstone.wiki.gg/wiki/Bolf_Ramshield | Loaded. Last edited 30 June 2026. |
+| https://hearthstone.wiki.gg/wiki/Armor | Loaded. Last edited 6 February 2026. |
+
+**Bolf Ramshield.** 6-mana 3/9 Legendary minion, Neutral, The Grand Tournament. It is a hero-damage redirect, so the course uses it (spec §3.2) instead of the fallback `Test Bodyguard`.
+- Card text: "Whenever your hero takes damage, this minion takes it instead."
+- Notes, kind of effect: "are all predamage triggers, that trigger when your hero takes damage, before the damage is taken:" and "it prevents the Damage Event targeting your hero and creates a new Damage Event".
+- Notes, several Bolfs: "the earliest played one is the one that will intercept the damage, due to play order."
+- Notes, mortally wounded: "Bolf Ramshield can still intercept damage while mortally wounded (0 or less Health) up until the current Phase ends"
+- Notes, ordering with another predamage effect: "Animated Armor will always reduce the damage to 1 before Bolf Ramshield intercepts it."
+- The page says nothing about the hero's Armor. Redirect before Armor is the course's choice.
+
+**Armor.**
+- "Damage taken will always be deducted from Armor before Health."
+- "Damage taken while protected by Armor still counts as taking damage for game purposes."
+- The page does not mention Bolf Ramshield or redirection.