A Deathrattle summon used the dying minion's board index from before the Death Creation Step removed anything. That index is stale when a minion to its left dies in the same step, so the token landed one place too far right. Each Death Event now records `before`: the nearest minion to the right that is not dying in the same step. The position handed to the effect context is resolved when the trigger resolves: the current index of `before`, or the right end. Applied to the Rust and TS references of Modules 3-10. Module 3 gains two tests, documented in scenarios.md: test_03_summon_position_after_simultaneous_deaths (fails before the fix) and test_03_simultaneous_summons_keep_board_order. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 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 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;
|
|
}
|
|
}
|