Files
systemic-game-design-course/course/04-interceptors/reference/ts/src/engine/queue.ts
T
Lucas WintherandClaude Opus 5.5 372c99c306 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>
2026-11-11 10:44:06 +01:00

260 lines
11 KiB
TypeScript

// The action queue: how one player action (a Sequence) resolves.
//
// Performing an action changes the world and produces events. Every trigger that
// responds to those events goes into one Queue, oldest listener first, and the Queue
// is locked. Each queued trigger then resolves completely, including everything
// its own actions cause, before the next one starts: depth-first.
//
// Module 3: nothing leaves play mid-Sequence. When the outermost Phase ends, the
// Death Creation Step removes the dead, and a Death Phase resolves their
// Deathrattles. That repeats until a step finds no new deaths.
//
// 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, redirectFor } from "./aura";
import type { Trigger, TriggerOn } from "./card";
import { deathCreationStep } from "./death";
import type { Event } from "./event";
import type { EntityId, PlayerId, World } from "./world";
/** Something the engine can do. Card effects return these instead of changing the world. */
export type Action =
/** Deal `amount` to each target at once: all damage first, then the reactions. */
| { type: "damage"; targets: EntityId[]; amount: number }
| { type: "draw"; player: PlayerId }
| { type: "gainArmor"; player: PlayerId; amount: number }
| { type: "gainAttack"; id: EntityId; amount: number }
| { type: "cast"; card: string; player: PlayerId; target?: EntityId }
/** Mark each target pending destroy. They leave play in the next Death Creation Step. */
| { type: "destroy"; targets: EntityId[] }
/** Summon `card` for `player` at board index `at`, or at the right end if `at` is undefined. */
| { type: "summon"; card: string; player: PlayerId; at?: number }
/** 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(
readonly world: World,
/** The player the effect works for. */
readonly owner: PlayerId,
/** The minion whose trigger is resolving; undefined for a spell. */
readonly source?: EntityId,
/** The chosen target, if any. */
readonly target?: EntityId,
/**
* The source's board position. For a Deathrattle, the source has left play, and
* this is where it stood: immediately left of the minion its Death Event remembers.
*/
readonly position?: number,
) {}
/** The minion whose trigger is resolving. */
minion(): EntityId {
if (this.source === undefined) throw new Error("only a triggered ability has a source");
return this.source;
}
enemyHero(): EntityId {
return this.world.hero(this.owner === 0 ? 1 : 0);
}
}
/** Run one Sequence: a player action and everything it causes. */
export function runSequence(world: World, action: Action): void {
perform(world, action);
// The outermost Phase has ended. Each Death Phase is itself an outermost
// Phase, so the end-of-Phase steps run again after it, until nothing dies.
let deaths = endOfOutermostPhase(world);
for (let round = 1; deaths.length > 0; round++) {
world.record({ type: "DeathPhase", round });
react(world, deaths);
deaths = endOfOutermostPhase(world);
}
}
/**
* The steps that run only when the outermost Phase ends; returns the Death Events.
* Hearthstone runs an Aura Update (Health/Attack) first. The course has no
* Health/Attack auras, so that step has nothing to do and is left out.
*/
function endOfOutermostPhase(world: World): Event[] {
const deaths = deathCreationStep(world);
// After the step: a minion that just died no longer contributes its auras
// to the Death Phase that resolves these deaths.
auraUpdateOther(world);
return deaths;
}
/** Summon `card` for `player` at board index `at` (or the right end). Summoning runs an Aura Update. */
export function summon(world: World, card: string, player: PlayerId, at?: number): EntityId {
const id = world.create(card, player, "board", at);
auraUpdateOther(world);
return id;
}
function perform(world: World, action: Action): void {
switch (action.type) {
case "damage": {
// 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) events.push(applyDamage(world, target, action.amount));
react(world, events);
break;
}
case "draw":
world.draw(action.player);
break;
case "gainArmor":
world.gainArmor(action.player, action.amount);
break;
case "gainAttack":
world.gainAttack(action.id, action.amount);
break;
case "destroy":
for (const target of action.targets) world.markDestroyed(target);
break;
case "summon":
summon(world, action.card, action.player, action.at);
break;
case "play": {
if (world.zoneOf(action.id) !== "hand") throw new Error(`play: entity ${action.id} is not in a hand`);
// Playing is not summoning: no Aura Update here. The played minion's own
// auras join at the end of the outermost Phase.
world.moveTo(action.id, "board");
const played: Event = { type: "Played", id: action.id };
world.record(played);
react(world, [played]);
break;
}
case "cast": {
const effect = world.card(action.card).onCast;
if (!effect) throw new Error(`"${action.card}" is not a spell`);
world.record({ type: "SpellCast", card: action.card });
for (const next of effect(new Ctx(world, action.player, undefined, action.target))) perform(world, next);
break;
}
}
}
/**
* 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.
const queue: [EntityId, Trigger, Event][] = [];
for (const event of events) {
for (const listener of listeners(world, event)) {
for (const trigger of world.card(world.cardName(listener)).triggers) {
if (responds(world, trigger.on, listener, event)) queue.push([listener, trigger, event]);
}
}
}
// The Queue is now locked: nothing that happens below adds to it.
for (const [source, trigger, event] of queue) {
// The interceptor: the entry was queued once; the auras in force when it
// starts decide how many times it activates, back to back.
const times = activations(world, world.ownerOf(source), trigger.on);
for (let i = 0; i < times; i++) {
world.record({ type: "Triggered", source, on: trigger.on });
const position =
event.type === "Destroyed" && event.id === source
? positionLeftOf(world, world.ownerOf(source), event.before)
: world.boardIndex(source);
const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position));
// The trigger's consequences are one level deeper in the log tree.
world.openBlock();
// Depth-first: each action, and every reaction it causes, finishes before the next.
for (const action of actions) perform(world, action);
world.closeBlock();
}
}
}
/**
* The board index, right now, immediately left of `before` on `owner`'s board; the
* right end if `before` is undefined or no longer there. A Death Event remembers the
* minion, not an index, so this still holds after other minions have left or arrived.
*/
function positionLeftOf(world: World, owner: PlayerId, before: EntityId | undefined): number {
const board = world.zone(owner, "board");
const index = before === undefined ? -1 : board.indexOf(before);
return index < 0 ? board.length : index;
}
/**
* Who may respond to `event`, in order of play: every minion in play and, for a
* Death Event, the minion that died. It has left play, but its Deathrattle still answers.
*/
function listeners(world: World, event: Event): EntityId[] {
const inPlay = world.minionsInPlay();
if (event.type !== "Destroyed") return inPlay;
return [...inPlay, event.id].sort((a, b) => world.orderOfPlay(a)! - world.orderOfPlay(b)!);
}
/** Does a trigger listening for `on`, belonging to `listener`, respond to `event`? */
function responds(world: World, on: TriggerOn, listener: EntityId, event: Event): boolean {
if (event.type === "Destroyed") return on === "deathrattle" && event.id === listener;
if (event.type === "Played") return on === "battlecry" && event.id === listener;
if (event.type !== "Damaged") return false;
const isMinion = !world.isHero(event.target);
switch (on) {
case "selfDamaged":
return event.target === listener;
case "friendlyMinionDamaged":
return isMinion && world.ownerOf(event.target) === world.ownerOf(listener);
case "anyMinionDamaged":
return isMinion;
case "heroDamaged":
return !isMinion;
case "deathrattle":
case "battlecry":
return false;
}
}