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