Files
systemic-game-design-course/course/05-assemble/reference/ts/src/engine/world.ts
T
Lucas WintherandClaude Fable 5.2 429137a6ef fix(reference): Death Events remember a right-hand neighbour, not a board index
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>
2026-11-11 09:15:48 +01:00

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