feat: module 04 interceptors
Trigger multipliers as aura data on cards, snapshotted by an Aura Update (Other) after each Death Creation Step and on summon, and applied by one generic routine (activations) when each queued trigger starts. Baron Rivendare is content only (stacking "max"); Brann Bronzebeard is the content-only Expansion Drop. Adds play (now required) and Battlecry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8a4eba1948
commit
bcdaca0cd2
@@ -0,0 +1,57 @@
|
||||
// Content: card definitions. Only this directory may name specific cards.
|
||||
//
|
||||
// Each effect is a small function that returns engine actions. Module 5
|
||||
// replaces these functions with card data the engine interprets. Auras are
|
||||
// already data: the engine reads them, and no engine code knows these cards.
|
||||
|
||||
import { CardLibrary, minion, spell } from "../engine";
|
||||
|
||||
/** Every card this game knows. */
|
||||
export function library(): CardLibrary {
|
||||
return new CardLibrary([
|
||||
// Fictional test minion with no text.
|
||||
minion("Test Filler", 1, 1),
|
||||
// "Whenever a friendly minion takes damage, gain 1 Armor."
|
||||
minion("Armorsmith", 1, 4, [
|
||||
{ on: "friendlyMinionDamaged", effect: (c) => [{ type: "gainArmor", player: c.owner, amount: 1 }] },
|
||||
]),
|
||||
// "Whenever this minion takes damage, draw a card."
|
||||
minion("Acolyte of Pain", 1, 4, [{ on: "selfDamaged", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// Fictional: "Whenever a minion takes damage, deal 1 damage to the enemy hero."
|
||||
minion("Test Echo", 1, 5, [
|
||||
{ on: "anyMinionDamaged", effect: (c) => [{ type: "damage", targets: [c.enemyHero()], amount: 1 }] },
|
||||
]),
|
||||
// Fictional: "Whenever a hero takes damage, gain +1 Attack."
|
||||
minion("Test Reactor", 1, 5, [
|
||||
{ on: "heroDamaged", effect: (c) => [{ type: "gainAttack", id: c.minion(), amount: 1 }] },
|
||||
]),
|
||||
// "Deathrattle: Draw a card."
|
||||
minion("Loot Hoarder", 2, 1, [{ on: "deathrattle", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// "Deathrattle: Summon a 2/1 Damaged Golem."
|
||||
minion("Harvest Golem", 2, 3, [
|
||||
{
|
||||
on: "deathrattle",
|
||||
effect: (c) => [{ type: "summon", card: "Damaged Golem", player: c.owner, at: c.position }],
|
||||
},
|
||||
]),
|
||||
// Summoned by Harvest Golem; no text.
|
||||
minion("Damaged Golem", 2, 1),
|
||||
// "Taunt. Deathrattle: Deal 1 damage to all minions." The course has no combat, so no Taunt.
|
||||
minion("Unstable Ghoul", 1, 3, [
|
||||
{
|
||||
on: "deathrattle",
|
||||
effect: (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }],
|
||||
},
|
||||
]),
|
||||
// "Your minions trigger their Deathrattles twice." Two Barons still only double.
|
||||
minion("Baron Rivendare", 1, 7, [], [
|
||||
{ type: "triggerMultiplier", trigger: "deathrattle", times: 2, stacking: "max" },
|
||||
]),
|
||||
// "Battlecry: Draw a card."
|
||||
minion("Novice Engineer", 1, 1, [{ on: "battlecry", effect: (c) => [{ type: "draw", player: c.owner }] }]),
|
||||
// "Deal 1 damage to ALL minions."
|
||||
spell("Whirlwind", (c) => [{ type: "damage", targets: c.world.minionsInPlay(), amount: 1 }]),
|
||||
// "Destroy all minions and locations." The course has no locations. (Module 3 Expansion Drop.)
|
||||
spell("Twisting Nether", (c) => [{ type: "destroy", targets: c.world.minionsInPlay() }]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Auras, and the one routine that applies trigger multipliers.
|
||||
//
|
||||
// An aura is data on a card: a continuous effect its minion has while in play.
|
||||
// The engine never asks "is that card on the board?" in the middle of resolving
|
||||
// something. It takes a snapshot of the auras in force at defined points (an Aura
|
||||
// Update) and every rule reads the snapshot.
|
||||
//
|
||||
// Hearthstone runs two kinds of Aura Update when the outermost Phase ends: one for
|
||||
// Health/Attack auras before the Death Creation Step, and one for every other aura
|
||||
// after it. Trigger multipliers are "Other" auras, so a minion that dies in a Death
|
||||
// Creation Step no longer multiplies the Deathrattles of that step's Death Phase.
|
||||
// Summoning a minion also runs an Aura Update.
|
||||
|
||||
import type { TriggerOn } from "./card";
|
||||
import type { PlayerId, World } from "./world";
|
||||
|
||||
/** Aura Update (Other): replace the snapshot with the auras of every minion now in play. */
|
||||
export function auraUpdateOther(world: World): void {
|
||||
world.setActiveAuras(
|
||||
world.minionsInPlay().flatMap((id) =>
|
||||
world.card(world.cardName(id)).auras.map((aura) => ({ owner: world.ownerOf(id), aura })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a trigger listening for `on`, on a minion `owner` controls, activates
|
||||
* when it resolves. Reads the snapshot from the last Aura Update. With no multiplier in
|
||||
* force the answer is 1. Multipliers with `stacking: "max"` contribute their largest
|
||||
* `times`; each `"additive"` one adds `times - 1` extra activations.
|
||||
*/
|
||||
export function activations(world: World, owner: PlayerId, on: TriggerOn): number {
|
||||
let max = 1;
|
||||
let extra = 0;
|
||||
for (const active of world.activeAuras()) {
|
||||
const { trigger, times, stacking } = active.aura;
|
||||
if (active.owner !== owner || trigger !== on) continue;
|
||||
if (stacking === "max") max = Math.max(max, times);
|
||||
else extra += times - 1;
|
||||
}
|
||||
return max + extra;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Card definitions as the engine sees them. The definitions themselves live in `content`.
|
||||
|
||||
import type { Action, Ctx } from "./queue";
|
||||
|
||||
/**
|
||||
* A card's behaviour: given where and for whom it runs, the actions to perform.
|
||||
* Module 2 writes these as functions in `content`; Module 5 turns them into data.
|
||||
*/
|
||||
export type EffectFn = (ctx: Ctx) => Action[];
|
||||
|
||||
/** What a triggered ability listens for. */
|
||||
export type TriggerOn =
|
||||
| "selfDamaged" // this minion takes damage
|
||||
| "friendlyMinionDamaged" // a minion with the same owner (this one included) takes damage
|
||||
| "anyMinionDamaged" // any minion takes damage
|
||||
| "heroDamaged" // either hero takes damage
|
||||
| "deathrattle" // this minion dies (it resolves after the minion has left play)
|
||||
| "battlecry"; // this minion is played from hand
|
||||
|
||||
/** The trigger's family name, as reported in `Triggered` events. */
|
||||
export function triggerLabel(on: TriggerOn): string {
|
||||
switch (on) {
|
||||
case "selfDamaged":
|
||||
case "friendlyMinionDamaged":
|
||||
case "anyMinionDamaged":
|
||||
case "heroDamaged":
|
||||
return "damaged";
|
||||
case "deathrattle":
|
||||
return "deathrattle";
|
||||
case "battlecry":
|
||||
return "battlecry";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An aura: a continuous effect a minion has while it is in play. The engine reads
|
||||
* auras only at the Aura Update points (see `aura.ts`), never mid-Phase.
|
||||
*
|
||||
* `triggerMultiplier`: triggers listening for `trigger`, belonging to minions with
|
||||
* the aura owner's owner, activate `times` times each. `stacking` says how several
|
||||
* multipliers for the same trigger combine: `"max"` takes the largest (two copies of
|
||||
* a doubler still double); `"additive"` adds each copy's extra activations.
|
||||
*/
|
||||
export type Aura = { type: "triggerMultiplier"; trigger: TriggerOn; times: number; stacking: "max" | "additive" };
|
||||
|
||||
export interface Trigger {
|
||||
on: TriggerOn;
|
||||
effect: EffectFn;
|
||||
}
|
||||
|
||||
/** What the engine needs to know about a card. */
|
||||
export interface CardDef {
|
||||
name: string;
|
||||
attack: number;
|
||||
health: number;
|
||||
/** Triggered abilities, in printed order. */
|
||||
triggers: Trigger[];
|
||||
/** Auras this minion has while in play. */
|
||||
auras: Aura[];
|
||||
/** A spell's effect when cast; absent for minions. */
|
||||
onCast?: EffectFn;
|
||||
}
|
||||
|
||||
export function minion(
|
||||
name: string,
|
||||
attack: number,
|
||||
health: number,
|
||||
triggers: Trigger[] = [],
|
||||
auras: Aura[] = [],
|
||||
): CardDef {
|
||||
return { name, attack, health, triggers, auras };
|
||||
}
|
||||
|
||||
export function spell(name: string, onCast: EffectFn): CardDef {
|
||||
return { name, attack: 0, health: 0, triggers: [], auras: [], onCast };
|
||||
}
|
||||
|
||||
/** Every card the game can create, looked up by name. */
|
||||
export class CardLibrary {
|
||||
private readonly cards = new Map<string, CardDef>();
|
||||
|
||||
constructor(defs: CardDef[]) {
|
||||
for (const def of defs) this.cards.set(def.name, def);
|
||||
}
|
||||
|
||||
get(name: string): CardDef {
|
||||
const def = this.cards.get(name);
|
||||
if (!def) throw new Error(`unknown card "${name}": define it in src/content`);
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The Death Creation Step: the one place minions leave play.
|
||||
//
|
||||
// Damage and destroy effects only mark a minion (0 or less Health, or pending
|
||||
// destroy). It stays in play, and keeps triggering, until the outermost Phase
|
||||
// ends. Then this step removes every marked minion at once.
|
||||
|
||||
import type { Event } from "./event";
|
||||
import type { World } from "./world";
|
||||
|
||||
/**
|
||||
* Remove every mortally wounded or pending-destroy minion from play, simultaneously.
|
||||
* Returns their Death Events in order of play; empty if nothing died.
|
||||
*/
|
||||
export function deathCreationStep(world: World): Event[] {
|
||||
const dying = world.minionsInPlay().filter((id) => world.isDoomed(id));
|
||||
// Every position is read before anything moves: removal is simultaneous, and each
|
||||
// Death Event remembers where its minion stood when the step began.
|
||||
const deaths = dying.map((id) => ({ type: "Destroyed" as const, id, position: world.boardIndex(id)! }));
|
||||
for (const { id, position } of deaths) world.kill(id, position);
|
||||
return deaths;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// What happened, in order. The engine records an event each time it changes the game.
|
||||
|
||||
import type { TriggerOn } from "./card";
|
||||
import type { EntityId, PlayerId } from "./world";
|
||||
|
||||
export type Event =
|
||||
| { type: "Summoned"; id: EntityId }
|
||||
| { type: "Damaged"; target: EntityId; amount: number }
|
||||
| { type: "Triggered"; source: EntityId; on: TriggerOn }
|
||||
| { type: "Drew"; player: PlayerId; id: EntityId }
|
||||
| { type: "Fatigue"; player: PlayerId }
|
||||
| { type: "ArmorGained"; player: PlayerId; amount: number }
|
||||
| { type: "StatsChanged"; id: EntityId; attack: number; health: number }
|
||||
| { type: "SpellCast"; card: string }
|
||||
/**
|
||||
* A Death Event: the Death Creation Step moved `id` from play to the graveyard.
|
||||
* `position` is the board index it had when the step began; its Deathrattle summons there.
|
||||
*/
|
||||
| { type: "Destroyed"; id: EntityId; position: number }
|
||||
| { type: "DeathPhase"; round: number }
|
||||
/** A minion was played from hand (it has just entered the board). Its Battlecry answers this. */
|
||||
| { type: "Played"; id: EntityId };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Engine core: generic rules machinery. Never name a specific card here.
|
||||
//
|
||||
// Module 1: every card in the game is an entity addressed by an id handle.
|
||||
// The engine knows which zone each entity is in, who owns it, and when it
|
||||
// entered play.
|
||||
//
|
||||
// Module 2: heroes, Health, Attack and Armor; triggered abilities; and the
|
||||
// action queue that resolves a Sequence depth-first, recording an event log.
|
||||
//
|
||||
// Module 3: deferred death. Damage and destroy effects only mark minions; the
|
||||
// Death Creation Step removes them when the outermost Phase ends, and Death
|
||||
// Phases resolve their Deathrattles until no new deaths occur.
|
||||
//
|
||||
// Module 4: auras as card data, Aura Updates at defined points, and one
|
||||
// routine that decides how many times a queued trigger activates. Playing a
|
||||
// minion from hand, and Battlecries.
|
||||
|
||||
export {
|
||||
CardLibrary,
|
||||
minion,
|
||||
spell,
|
||||
triggerLabel,
|
||||
type Aura,
|
||||
type CardDef,
|
||||
type EffectFn,
|
||||
type Trigger,
|
||||
type TriggerOn,
|
||||
} from "./card";
|
||||
export type { Event } from "./event";
|
||||
export { Ctx, runSequence, summon, type Action } from "./queue";
|
||||
export { HERO_HEALTH, World, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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.
|
||||
|
||||
import { activations, auraUpdateOther } 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 };
|
||||
|
||||
/** 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 the position its Death Event remembered.
|
||||
*/
|
||||
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) {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ? event.position : world.boardIndex(source);
|
||||
const actions = trigger.effect(new Ctx(world, world.ownerOf(source), source, undefined, position));
|
||||
// Depth-first: each action, and every reaction it causes, finishes before the next.
|
||||
for (const action of actions) perform(world, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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, 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;
|
||||
}
|
||||
|
||||
/** 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[] = [];
|
||||
|
||||
/** 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,
|
||||
health: def.health,
|
||||
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): CardDef {
|
||||
return this.library.get(name);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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, position: number): void {
|
||||
this.entity(id).pendingDestroy = false;
|
||||
this.moveTo(id, "graveyard");
|
||||
this.record({ type: "Destroyed", id, position });
|
||||
}
|
||||
|
||||
gainArmor(player: PlayerId, amount: number): void {
|
||||
this.sides[player].armor += amount;
|
||||
this.record({ type: "ArmorGained", player, amount });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Test harness: the thin adapter the course tests call. It translates each
|
||||
// call into engine operations and holds no rules of its own.
|
||||
|
||||
import { library } from "./content";
|
||||
import { runSequence, summon, triggerLabel, World, type Event, type Zone } from "./engine";
|
||||
|
||||
export type { Zone };
|
||||
|
||||
export type Id = number;
|
||||
export type Player = 0 | 1;
|
||||
|
||||
/** The events the tests read from `log()` (see course/harness.md). */
|
||||
export type GameEvent =
|
||||
| { type: "Summoned"; id: Id }
|
||||
| { type: "Damaged"; target: Id; amount: number }
|
||||
| { type: "Triggered"; source: Id; trigger: string }
|
||||
| { type: "Destroyed"; id: Id }
|
||||
| { type: "DeathPhase"; round: number }
|
||||
| { type: "Drew"; player: Player; id: Id }
|
||||
| { type: "Fatigue"; player: Player }
|
||||
| { type: "ArmorGained"; player: Player; amount: number }
|
||||
| { type: "StatsChanged"; id: Id; attack: number; health: number }
|
||||
| { type: "SpellCast"; card: string };
|
||||
|
||||
/** The course's view of an engine event; `undefined` for engine events the course log doesn't show. */
|
||||
function toGameEvent(event: Event): GameEvent | undefined {
|
||||
if (event.type === "Triggered") return { type: "Triggered", source: event.source, trigger: triggerLabel(event.on) };
|
||||
if (event.type === "Destroyed") return { type: "Destroyed", id: event.id };
|
||||
// A played minion's `Summoned` already shows it entering the board.
|
||||
if (event.type === "Played") return undefined;
|
||||
return { ...event };
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
summon(card: string, player?: Player): Id; // to right end of board
|
||||
addToHand(card: string, player?: Player): Id;
|
||||
addToDeck(card: string, player?: Player): Id;
|
||||
play(handId: Id): void; // Module 1 Expansion Drop; required from Module 4 (Battlecry)
|
||||
zoneOf(id: Id): Zone;
|
||||
board(player: Player): Id[]; // left to right
|
||||
hand(player: Player): Id[];
|
||||
orderOfPlay(id: Id): number;
|
||||
nameOf(id: Id): string;
|
||||
// Module 2
|
||||
hero(player: Player): Id;
|
||||
dealDamage(target: Id, amount: number): void; // one Sequence
|
||||
cast(card: string, player?: Player, target?: Id): void;
|
||||
health(id: Id): number;
|
||||
attack(id: Id): number;
|
||||
armor(player: Player): number;
|
||||
log(): GameEvent[];
|
||||
clearLog(): void;
|
||||
// Module 3
|
||||
destroy(target: Id): void; // marks pending destroy, one Sequence
|
||||
}
|
||||
|
||||
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
|
||||
const world = new World(library());
|
||||
opts?.decks?.forEach((deck, player) => {
|
||||
for (const card of deck) world.create(card, player as Player, "deck");
|
||||
});
|
||||
|
||||
return {
|
||||
summon: (card, player = 0) => summon(world, card, player),
|
||||
addToHand: (card, player = 0) => world.create(card, player, "hand"),
|
||||
addToDeck: (card, player = 0) => world.create(card, player, "deck"),
|
||||
// Play a card from hand, as one Sequence: it enters the right end of the board, then its Battlecry resolves.
|
||||
play: (handId) => runSequence(world, { type: "play", id: handId }),
|
||||
zoneOf: (id) => world.zoneOf(id),
|
||||
board: (player) => [...world.zone(player, "board")],
|
||||
hand: (player) => [...world.zone(player, "hand")],
|
||||
orderOfPlay: (id) => {
|
||||
const order = world.orderOfPlay(id);
|
||||
if (order === undefined) throw new Error(`entity ${id} has not entered play`);
|
||||
return order;
|
||||
},
|
||||
nameOf: (id) => world.cardName(id),
|
||||
|
||||
hero: (player) => world.hero(player),
|
||||
dealDamage: (target, amount) => runSequence(world, { type: "damage", targets: [target], amount }),
|
||||
cast: (card, player = 0, target) => runSequence(world, { type: "cast", card, player, target }),
|
||||
health: (id) => world.health(id),
|
||||
attack: (id) => world.attack(id),
|
||||
armor: (player) => world.armor(player),
|
||||
log: () => world.log().flatMap((event) => toGameEvent(event) ?? []),
|
||||
clearLog: () => world.clearLog(),
|
||||
|
||||
destroy: (target) => runSequence(world, { type: "destroy", targets: [target] }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user