feat: replacement effects in Module 4 (Armor absorption, Bolf Ramshield redirect)

Applying a Damage to one target now runs DAMAGE_STEPS = [redirect, armor]
first. A damageRedirect aura (Bolf Ramshield, fetched from the wiki) sends
damage meant for its owner's hero to the oldest such minion; Armor absorbs
damage to a hero before Health, logged as the new ArmorLost{player, amount}
just before a full-amount Damaged. Four new Module 4 tests; propagated to the
references of Modules 4-10 in both tracks; card schema, catalog, card JSON,
harness.md, Lessons 2, 4 and 5 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 10:44:06 +01:00
1 parent 97c64ebee8
commit 372c99c306
113 files changed
+1925 -231

No files matched your search

+3 -3
View File
@@ -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.
</details>
@@ -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,
@@ -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" },
+146 -11
View File
@@ -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.
<details><summary>Hint — concept</summary>
- **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".
</details>
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.
@@ -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 }]
@@ -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<EntityId> {
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;
}
@@ -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)]
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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."
@@ -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;
}
@@ -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;
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
+44
View File
@@ -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.
@@ -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);
}
@@ -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);
});
+1 -1
View File
@@ -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.
@@ -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<EntityId> {
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;
}
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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";
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
@@ -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<EntityId> {
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;
}
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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";
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
@@ -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<EntityId> {
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;
}
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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";
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
@@ -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<EntityId> {
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;
}
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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";
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
@@ -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<EntityId> {
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;
}
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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";
@@ -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 }
/**
@@ -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";
@@ -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.
@@ -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.
@@ -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 };
@@ -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<EntityId> {
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
@@ -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"`.
@@ -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,
@@ -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};
@@ -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.
@@ -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<EntityId>,
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) {
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more