// 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 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. // // Module 10: the steps at the end of the outermost Phase are a list, `END_OF_PHASE`, // read from the world, as Part II's turn is a list of phases. SpellGrid's tests run // the same Sequence with the list reversed, to show what the order decides. import { assembleFromPool } from "./assemble"; import { activations, auraUpdateOther } from "./aura"; import type { Effect, PoolFilter, Trigger, TriggerOn } from "./card"; import { deathCreationStep } from "./death"; import { actions } from "./effect"; 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 } /** Put an Assembled minion into `player`'s hand (see `assemble.ts`). */ | { type: "assemble"; player: PlayerId; filter: PoolFilter } /** Give one entity +attack/+health. */ | { type: "buff"; id: EntityId; attack: number; health: number } /** Ask for `word` to be written on `source`'s grid tile (Module 10). Part I only records it. */ | { type: "spawnText"; source: EntityId; word: string }; /** * A step that runs when the outermost Phase ends: * - `"deathCreation"`: the Death Creation Step (Module 3); * - `"auraUpdate"`: an Aura Update (Other) (Module 4). */ export type EndOfPhaseStep = "deathCreation" | "auraUpdate"; /** * The end of the outermost Phase, in Hearthstone's order: the dead leave play first, * then the auras are read again, so a minion that just died multiplies nothing in * the Death Phase that follows. */ export const END_OF_PHASE: readonly EndOfPhaseStep[] = ["deathCreation", "auraUpdate"]; /** 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, in the world's order * (`END_OF_PHASE` unless changed); 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[] { let deaths: Event[] = []; for (const step of world.endOfPhase()) { switch (step) { case "deathCreation": deaths = deathCreationStep(world); break; case "auraUpdate": auraUpdateOther(world); break; } } 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) { world.damage(target, action.amount); events.push({ type: "Damaged", target, amount: 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 spell = world.card(action.card); if (spell.def.type !== "spell") throw new Error(`"${action.card}" is not a spell`); world.record({ type: "SpellCast", card: action.card }); runEffects(world, spell.onCast, new Ctx(world, action.player, undefined, action.target)); break; } case "assemble": assembleFromPool(world, action.player, action.filter); break; case "buff": world.buff(action.id, action.attack, action.health); break; case "spawnText": world.record({ type: "TextSpawned", source: action.source, word: action.word }); break; } } /** * Interpret and perform an ability's effects in printed order. Each effect sees the * world as the effects before it left it. Depth-first: each action, and every * reaction it causes, finishes before the next. */ function runEffects(world: World, effects: Effect[], ctx: Ctx): void { for (const effect of effects) { for (const action of actions(effect, ctx)) perform(world, action); } } /** 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); runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position)); } } } /** * 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": case "endOfTurn": return false; } }