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

@@ -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 };