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>
260 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|