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