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:
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user