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>
302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
// Entities, zones, order of play, stats and the event log: the game's state.
|
|
// The rules for resolving actions live in `queue.ts`.
|
|
|
|
import type { Aura, Card, CardDef, CardLibrary } from "./card";
|
|
import type { Event } from "./event";
|
|
|
|
/** A handle to an entity. Ids are never reused and stay valid when the entity changes zone. */
|
|
export type EntityId = number;
|
|
export type PlayerId = 0 | 1;
|
|
export type Zone = "deck" | "hand" | "board" | "graveyard";
|
|
|
|
/** Starting Health of each hero. */
|
|
export const HERO_HEALTH = 30;
|
|
|
|
interface Entity {
|
|
card: string;
|
|
owner: PlayerId;
|
|
zone: Zone;
|
|
/** Stamped each time the entity enters the board; undefined until it first does. */
|
|
orderOfPlay?: number;
|
|
attack: number;
|
|
/** Current Health. At 0 or below the entity is mortally wounded: still in play until the Death Creation Step. */
|
|
health: number;
|
|
/** Hit by a destroy effect: still in play until the Death Creation Step. */
|
|
pendingDestroy: boolean;
|
|
}
|
|
|
|
/** An aura in force: which player's minion has it. */
|
|
export interface ActiveAura {
|
|
owner: PlayerId;
|
|
aura: Aura;
|
|
}
|
|
|
|
/**
|
|
* Makes a player's choices: called with what is being chosen and the options, it
|
|
* returns one of the options. Hearthstone would ask the player or pick at random.
|
|
*/
|
|
export type Chooser = (kind: string, options: readonly string[]) => string;
|
|
|
|
/** One player's zones (each an ordered list of ids; the board is left to right), hero and Armor. */
|
|
type Side = Record<Zone, EntityId[]> & { hero: EntityId; armor: number };
|
|
|
|
export class World {
|
|
/** Indexed by EntityId. */
|
|
private readonly entities: Entity[] = [];
|
|
private readonly sides: [Side, Side];
|
|
private nextOrderOfPlay = 0;
|
|
private events: Event[] = [];
|
|
/** The auras in force as of the last Aura Update. Changes only there (see `aura.ts`). */
|
|
private auras: ActiveAura[] = [];
|
|
/** Until a chooser is set, take the first option. */
|
|
private chooser: Chooser = (_kind, options) => options[0];
|
|
|
|
/** A new game: both heroes are in play from the start, so they are the oldest entities. */
|
|
constructor(private readonly library: CardLibrary) {
|
|
const side = (owner: PlayerId): Side => {
|
|
const hero = this.entities.length;
|
|
this.entities.push({
|
|
card: "Hero",
|
|
owner,
|
|
zone: "board",
|
|
orderOfPlay: ++this.nextOrderOfPlay,
|
|
attack: 0,
|
|
health: HERO_HEALTH,
|
|
pendingDestroy: false,
|
|
});
|
|
// In play, but not on the board list: the board holds minions only.
|
|
return { deck: [], hand: [], board: [], graveyard: [], hero, armor: 0 };
|
|
};
|
|
this.sides = [side(0), side(1)];
|
|
}
|
|
|
|
/**
|
|
* Create an entity of `card` for `owner` in `zone`: at the end (the right end for the board),
|
|
* or at index `at` when given (clamped to the zone's length).
|
|
*/
|
|
create(card: string, owner: PlayerId, zone: Zone, at?: number): EntityId {
|
|
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
|
const { def } = this.library.get(card);
|
|
const id = this.entities.length;
|
|
this.entities.push({
|
|
card: def.name,
|
|
owner,
|
|
zone,
|
|
attack: def.attack ?? 0,
|
|
health: def.health ?? 0,
|
|
pendingDestroy: false,
|
|
});
|
|
this.enter(id, zone, at);
|
|
return id;
|
|
}
|
|
|
|
/** Move an entity to the end of `zone` on its owner's side, keeping its id. */
|
|
moveTo(id: EntityId, zone: Zone): void {
|
|
const { owner, zone: from } = this.entity(id);
|
|
const list = this.sides[owner][from];
|
|
list.splice(list.indexOf(id), 1);
|
|
this.enter(id, zone);
|
|
}
|
|
|
|
/** The single place an entity arrives in a zone. Entering the board starts a new order of play. */
|
|
private enter(id: EntityId, zone: Zone, at?: number): void {
|
|
const entity = this.entity(id);
|
|
const list = this.sides[entity.owner][zone];
|
|
list.splice(at === undefined ? list.length : Math.min(at, list.length), 0, id);
|
|
entity.zone = zone;
|
|
if (zone === "board") {
|
|
entity.orderOfPlay = ++this.nextOrderOfPlay;
|
|
this.record({ type: "Summoned", id });
|
|
}
|
|
}
|
|
|
|
private entity(id: EntityId): Entity {
|
|
const entity = this.entities[id];
|
|
if (!entity) throw new Error(`unknown entity id ${id}`);
|
|
return entity;
|
|
}
|
|
|
|
// ---- Queries ----
|
|
|
|
card(name: string): Card {
|
|
return this.library.get(name);
|
|
}
|
|
|
|
hasCard(name: string): boolean {
|
|
return this.library.has(name);
|
|
}
|
|
|
|
/** The cards an Assemble may offer, sorted by name. */
|
|
pool(): Card[] {
|
|
return this.library.pool();
|
|
}
|
|
|
|
zoneOf(id: EntityId): Zone {
|
|
return this.entity(id).zone;
|
|
}
|
|
|
|
cardName(id: EntityId): string {
|
|
return this.entity(id).card;
|
|
}
|
|
|
|
ownerOf(id: EntityId): PlayerId {
|
|
return this.entity(id).owner;
|
|
}
|
|
|
|
/** When the entity last entered the board (larger is later); undefined if it never has. */
|
|
orderOfPlay(id: EntityId): number | undefined {
|
|
return this.entity(id).orderOfPlay;
|
|
}
|
|
|
|
/** The ids in one player's zone, in zone order (left to right for the board). */
|
|
zone(owner: PlayerId, zone: Zone): readonly EntityId[] {
|
|
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
|
return this.sides[owner][zone];
|
|
}
|
|
|
|
hero(owner: PlayerId): EntityId {
|
|
if (owner !== 0 && owner !== 1) throw new Error(`no player ${owner}`);
|
|
return this.sides[owner].hero;
|
|
}
|
|
|
|
isHero(id: EntityId): boolean {
|
|
return this.sides.some((s) => s.hero === id);
|
|
}
|
|
|
|
/** Every minion on either board, oldest first (order of play). */
|
|
minionsInPlay(): EntityId[] {
|
|
return [...this.sides[0].board, ...this.sides[1].board].sort(
|
|
(a, b) => this.orderOfPlay(a)! - this.orderOfPlay(b)!,
|
|
);
|
|
}
|
|
|
|
attack(id: EntityId): number {
|
|
return this.entity(id).attack;
|
|
}
|
|
|
|
health(id: EntityId): number {
|
|
return this.entity(id).health;
|
|
}
|
|
|
|
/** The cost printed on the entity's card. */
|
|
cost(id: EntityId): number {
|
|
return this.card(this.cardName(id)).def.cost;
|
|
}
|
|
|
|
/** The minions in a player's hand, in hand order. */
|
|
minionsInHand(owner: PlayerId): EntityId[] {
|
|
return this.zone(owner, "hand").filter((id) => this.card(this.cardName(id)).def.type === "minion");
|
|
}
|
|
|
|
/** Mortally wounded (0 or less Health) or pending destroy: it leaves play at the next Death Creation Step. */
|
|
isDoomed(id: EntityId): boolean {
|
|
const entity = this.entity(id);
|
|
return entity.health <= 0 || entity.pendingDestroy;
|
|
}
|
|
|
|
/** Index of a minion on its owner's board, left to right; undefined if it is not on the board. */
|
|
boardIndex(id: EntityId): number | undefined {
|
|
const index = this.sides[this.ownerOf(id)].board.indexOf(id);
|
|
return index < 0 ? undefined : index;
|
|
}
|
|
|
|
armor(owner: PlayerId): number {
|
|
return this.sides[owner].armor;
|
|
}
|
|
|
|
activeAuras(): readonly ActiveAura[] {
|
|
return this.auras;
|
|
}
|
|
|
|
log(): readonly Event[] {
|
|
return this.events;
|
|
}
|
|
|
|
// ---- Changes: each one records its event ----
|
|
|
|
record(event: Event): void {
|
|
this.events.push(event);
|
|
}
|
|
|
|
/** Add a card to the library, or replace the card with the same name. */
|
|
register(def: CardDef): void {
|
|
this.library.register(def);
|
|
}
|
|
|
|
/** Add a card made by Assemble (see `assemble.ts`). */
|
|
registerAssembled(def: CardDef): void {
|
|
this.library.registerAssembled(def);
|
|
}
|
|
|
|
setChooser(chooser: Chooser): void {
|
|
this.chooser = chooser;
|
|
}
|
|
|
|
/** Ask the chooser to pick one of `options` (not empty). Throws if it answers something that isn't an option. */
|
|
choose(kind: string, options: readonly string[]): string {
|
|
const answer = this.chooser(kind, options);
|
|
if (!options.includes(answer)) {
|
|
throw new Error(`chooser answered "${answer}" for "${kind}"; the options were ${JSON.stringify(options)}`);
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
/** Only an Aura Update calls this. */
|
|
setActiveAuras(auras: ActiveAura[]): void {
|
|
this.auras = auras;
|
|
}
|
|
|
|
clearLog(): void {
|
|
this.events = [];
|
|
}
|
|
|
|
damage(target: EntityId, amount: number): void {
|
|
this.entity(target).health -= amount;
|
|
this.record({ type: "Damaged", target, amount });
|
|
}
|
|
|
|
/**
|
|
* Draw the top card of `player`'s deck (the first in the list). An empty deck
|
|
* records `Fatigue` and changes nothing else.
|
|
*/
|
|
draw(player: PlayerId): void {
|
|
const id = this.zone(player, "deck")[0];
|
|
if (id === undefined) {
|
|
this.record({ type: "Fatigue", player });
|
|
return;
|
|
}
|
|
this.moveTo(id, "hand");
|
|
this.record({ type: "Drew", player, id });
|
|
}
|
|
|
|
/** Mark for destruction. Nothing leaves play until the Death Creation Step. */
|
|
markDestroyed(id: EntityId): void {
|
|
this.entity(id).pendingDestroy = true;
|
|
}
|
|
|
|
/** Move a minion from play to its owner's graveyard, recording its Death Event. */
|
|
kill(id: EntityId, before: EntityId | undefined): void {
|
|
this.entity(id).pendingDestroy = false;
|
|
this.moveTo(id, "graveyard");
|
|
this.record({ type: "Destroyed", id, before });
|
|
}
|
|
|
|
gainArmor(player: PlayerId, amount: number): void {
|
|
this.sides[player].armor += amount;
|
|
this.record({ type: "ArmorGained", player, amount });
|
|
}
|
|
|
|
buff(id: EntityId, attack: number, health: number): void {
|
|
const entity = this.entity(id);
|
|
entity.attack += attack;
|
|
entity.health += health;
|
|
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
|
|
}
|
|
|
|
gainAttack(id: EntityId, amount: number): void {
|
|
const entity = this.entity(id);
|
|
entity.attack += amount;
|
|
this.record({ type: "StatsChanged", id, attack: entity.attack, health: entity.health });
|
|
}
|
|
}
|