feat: module 07 rule resolution
Adds IS NOT to the Part II parser and a resolution pass (negation, then
identity lock) that turns parsed rules into the rules in force; harness
gains activeRules() and hasProperty(). A leading NOT now breaks the
sentence instead of being ignored (carried from M6 review). Nouns say
which objects they name ("selects"), so the ALL Expansion Drop is one
line of data. Corrects lesson 06's NOT wording; harness.md M7 details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
68fe9834c3
commit
d993457772
58 files changed
+6091
-2
No files matched your search
@@ -0,0 +1,102 @@
|
||||
// Assemble: "Get a random minion. Change half its text."
|
||||
//
|
||||
// Card text is a list of clauses, so changing half of it is list surgery: the base
|
||||
// card's `text.slice(0, split)` (top) or `text.slice(split)` (bottom) is replaced by
|
||||
// the same half of a donor card. Name, cost and stats stay the base's. This works
|
||||
// only because a card's behaviour is its data: the new card needs no new code.
|
||||
//
|
||||
// Hearthstone's wiki doesn't define "half". The course's `split` field is its
|
||||
// definition: each card's data says where its top half ends.
|
||||
|
||||
import { hasTrigger, type CardDef, type PoolFilter } from "./card";
|
||||
import type { EntityId, PlayerId, World } from "./world";
|
||||
|
||||
export type Half = "top" | "bottom";
|
||||
const HALVES: Half[] = ["top", "bottom"];
|
||||
|
||||
/** Assembling `base` with `donor`'s `half` would leave the base's text unchanged. */
|
||||
export class AssembleError extends Error {
|
||||
constructor(
|
||||
readonly base: string,
|
||||
readonly donor: string,
|
||||
readonly half: Half,
|
||||
) {
|
||||
super(`assembling "${base}" with the ${half} half of "${donor}" leaves its text unchanged`);
|
||||
this.name = "AssembleError";
|
||||
}
|
||||
}
|
||||
|
||||
/** JSON with object keys sorted, so two clauses written with their keys in a different order compare equal. */
|
||||
const canonical = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, v) =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
: v,
|
||||
);
|
||||
const sameText = (a: CardDef["text"], b: CardDef["text"]): boolean => canonical(a) === canonical(b);
|
||||
|
||||
/**
|
||||
* The base card with `half` of its text replaced by the same half of `donor`.
|
||||
* It keeps the base's type, cost and stats, and is named "<base> (Assembled)".
|
||||
*/
|
||||
export function assemble(base: CardDef, donor: CardDef, half: Half): CardDef {
|
||||
const topOf = (c: CardDef) => c.text.slice(0, c.split);
|
||||
const bottomOf = (c: CardDef) => c.text.slice(c.split);
|
||||
const top = half === "top" ? topOf(donor) : topOf(base);
|
||||
const bottom = half === "bottom" ? bottomOf(donor) : bottomOf(base);
|
||||
const text = [...top, ...bottom];
|
||||
if (sameText(text, base.text)) throw new AssembleError(base.name, donor.name, half);
|
||||
return { ...base, name: `${base.name} (Assembled)`, text, split: top.length };
|
||||
}
|
||||
|
||||
const changes = (base: CardDef, donor: CardDef, half: Half): boolean => {
|
||||
try {
|
||||
assemble(base, donor, half);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AssembleError) return false;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register an assembled card and return its name. If a different card already has
|
||||
* the name (an earlier Assemble of the same base), a number is added: "(Assembled 2)".
|
||||
*/
|
||||
export function registerAssembled(world: World, def: CardDef): string {
|
||||
const stem = def.name.replace(/\)$/, "");
|
||||
let name = def.name;
|
||||
// Same base, so the same name and stats: only the text can differ.
|
||||
for (let n = 2; world.hasCard(name) && !sameText(world.card(name).def.text, def.text); n++) {
|
||||
name = `${stem} ${n})`;
|
||||
}
|
||||
world.registerAssembled({ ...def, name });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `assemble` effect: the owner's chooser picks a base minion from the pool
|
||||
* (filtered), a donor that changes its text, and the half to replace. The
|
||||
* Assembled minion goes to the owner's hand. Real Hearthstone picks the base at
|
||||
* random and offers two donors; here every choice is the chooser's.
|
||||
*/
|
||||
export function assembleFromPool(world: World, player: PlayerId, filter: PoolFilter): EntityId | undefined {
|
||||
const pool = world.pool();
|
||||
const bases = pool.filter((c) => filter.trigger === undefined || hasTrigger(c, filter.trigger)).map((c) => c.def.name);
|
||||
if (bases.length === 0) return undefined;
|
||||
const base = world.card(world.choose("base", bases)).def;
|
||||
|
||||
const donors = pool
|
||||
.map((c) => c.def)
|
||||
.filter((d) => d.name !== base.name && HALVES.some((h) => changes(base, d, h)))
|
||||
.map((d) => d.name);
|
||||
if (donors.length === 0) return undefined;
|
||||
const donor = world.card(world.choose("donor", donors)).def;
|
||||
|
||||
const half = world.choose(
|
||||
"half",
|
||||
HALVES.filter((h) => changes(base, donor, h)),
|
||||
) as Half;
|
||||
const name = registerAssembled(world, assemble(base, donor, half));
|
||||
return world.create(name, player, "hand");
|
||||
}
|
||||
@@ -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,175 @@
|
||||
// Cards as data. A `CardDef` is the course's card format (`course/schema/card.schema.json`),
|
||||
// read straight from JSON. The engine compiles each definition once, when it is
|
||||
// registered, into the lists it reads while resolving: triggers, auras and a
|
||||
// spell's effects. The definitions themselves live in `cards/`, loaded by `content`.
|
||||
|
||||
/** 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
|
||||
| "endOfTurn"; // the end of its owner's turn: the course has no turns, so it never fires
|
||||
|
||||
/** 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":
|
||||
case "battlecry":
|
||||
case "endOfTurn":
|
||||
return on;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" };
|
||||
|
||||
/** Who an effect touches. */
|
||||
export type Target = "self" | "allMinions" | "allOtherMinions" | "allOtherCharacters" | "enemyHero" | "chosen";
|
||||
|
||||
/** Which minions an Assemble may pick as its base. An empty filter allows any. */
|
||||
export interface PoolFilter {
|
||||
/** Only minions with a trigger clause listening for this. */
|
||||
trigger?: TriggerOn;
|
||||
}
|
||||
|
||||
/** One effect: a node of a card's ability, interpreted by `effect.ts`. */
|
||||
export type Effect =
|
||||
| { type: "dealDamage"; target: Target; amount: number }
|
||||
| { type: "draw"; count: number }
|
||||
| { type: "summon"; card: string; count: number }
|
||||
| { type: "gainArmor"; amount: number }
|
||||
| { type: "gainAttack"; target: Target; amount: number }
|
||||
| { type: "destroy"; target: Target }
|
||||
/** Part III (SpellGrid) only: puts a word on the grid. */
|
||||
| { type: "spawnText"; word: string; at: "self" }
|
||||
/** Get a minion from the pool, with half its text replaced (see `assemble.ts`). */
|
||||
| { type: "assemble"; filter: PoolFilter }
|
||||
/** Give every minion in the owner's hand +attack/+health. */
|
||||
| { type: "buffHand"; attack: number; health: number };
|
||||
|
||||
/**
|
||||
* One line of card text. A trigger clause applies to the effect clauses after it,
|
||||
* until the next trigger, keyword or aura. Effects before any trigger are what a
|
||||
* spell does when cast.
|
||||
*/
|
||||
export type Clause =
|
||||
| { kind: "keyword"; keyword: string }
|
||||
| { kind: "trigger"; on: TriggerOn }
|
||||
| { kind: "aura"; modifier: Aura }
|
||||
| { kind: "effect"; effect: Effect };
|
||||
|
||||
/** A card as data, in the course's card format. */
|
||||
export interface CardDef {
|
||||
name: string;
|
||||
type: "minion" | "spell";
|
||||
cost: number;
|
||||
attack?: number;
|
||||
health?: number;
|
||||
noun?: string;
|
||||
text: Clause[];
|
||||
/** `text.slice(0, split)` is the top half, `text.slice(split)` the bottom half. */
|
||||
split: number;
|
||||
fictional?: boolean;
|
||||
game?: string;
|
||||
}
|
||||
|
||||
/** A triggered ability: "whenever <on>, <effects>". */
|
||||
export interface Trigger {
|
||||
on: TriggerOn;
|
||||
effects: Effect[];
|
||||
}
|
||||
|
||||
/** A registered card: its definition, and the definition's text compiled into the lists the engine reads. */
|
||||
export interface Card {
|
||||
def: CardDef;
|
||||
/** Triggered abilities, in printed order. */
|
||||
triggers: Trigger[];
|
||||
/** Auras this minion has while in play. */
|
||||
auras: Aura[];
|
||||
/** What the card does when cast (a spell's effects). */
|
||||
onCast: Effect[];
|
||||
/** May an Assemble offer this card? Minions with text that were not themselves assembled. */
|
||||
inPool: boolean;
|
||||
}
|
||||
|
||||
/** Compile a definition's text. Throws if `split` is past the end of the text. */
|
||||
export function compile(def: CardDef, inPool: boolean): Card {
|
||||
if (def.split > def.text.length) throw new Error(`"${def.name}": split ${def.split} is past the end of its text`);
|
||||
const card: Card = { def, triggers: [], auras: [], onCast: [], inPool: false };
|
||||
// The trigger whose effects are still being listed, if any.
|
||||
let open: Trigger | undefined;
|
||||
for (const clause of def.text) {
|
||||
switch (clause.kind) {
|
||||
case "trigger":
|
||||
open = { on: clause.on, effects: [] };
|
||||
card.triggers.push(open);
|
||||
break;
|
||||
case "effect":
|
||||
(open ? open.effects : card.onCast).push(clause.effect);
|
||||
break;
|
||||
case "aura":
|
||||
card.auras.push(clause.modifier);
|
||||
open = undefined;
|
||||
break;
|
||||
case "keyword": // The course has no combat, so keywords such as Taunt do nothing here.
|
||||
open = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
card.inPool = inPool && def.type === "minion" && def.text.length > 0;
|
||||
return card;
|
||||
}
|
||||
|
||||
export const hasTrigger = (card: Card, on: TriggerOn): boolean => card.triggers.some((t) => t.on === on);
|
||||
|
||||
/** Every card the game can create, looked up by name. */
|
||||
export class CardLibrary {
|
||||
private readonly cards = new Map<string, Card>();
|
||||
|
||||
constructor(defs: CardDef[]) {
|
||||
for (const def of defs) this.register(def);
|
||||
}
|
||||
|
||||
/** Add a card, or replace the card with the same name. It joins the Assemble pool if it is a minion with text. */
|
||||
register(def: CardDef): void {
|
||||
this.cards.set(def.name, compile(def, true));
|
||||
}
|
||||
|
||||
/** Add a card made by Assemble. It never joins the pool. */
|
||||
registerAssembled(def: CardDef): void {
|
||||
this.cards.set(def.name, compile(def, false));
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.cards.has(name);
|
||||
}
|
||||
|
||||
get(name: string): Card {
|
||||
const card = this.cards.get(name);
|
||||
if (!card) throw new Error(`unknown card "${name}": add its file to cards/`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** The Assemble pool: every card with `inPool`, sorted by name. */
|
||||
pool(): Card[] {
|
||||
return [...this.cards.values()]
|
||||
.filter((c) => c.inPool)
|
||||
.sort((a, b) => (a.def.name < b.def.name ? -1 : a.def.name > b.def.name ? 1 : 0));
|
||||
}
|
||||
}
|
||||
@@ -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,68 @@
|
||||
// The interpreter: turns one effect, as data, into engine actions.
|
||||
//
|
||||
// This is the only place that knows what an effect *means*. A card's text is a
|
||||
// small tree (clauses holding effects holding targets); the engine walks it. The
|
||||
// effect vocabulary is closed: a card can only do what this switch can do, which is
|
||||
// what makes a data card safe to load.
|
||||
|
||||
import type { Effect, Target } from "./card";
|
||||
import type { Action, Ctx } from "./queue";
|
||||
import type { EntityId } from "./world";
|
||||
|
||||
const times = <T>(count: number, make: () => T): T[] => Array.from({ length: count }, make);
|
||||
|
||||
/** The actions one effect performs, in order, given where and for whom it runs. */
|
||||
export function actions(effect: Effect, ctx: Ctx): Action[] {
|
||||
const player = ctx.owner;
|
||||
switch (effect.type) {
|
||||
case "dealDamage":
|
||||
return [{ type: "damage", targets: targets(effect.target, ctx), amount: effect.amount }];
|
||||
case "draw":
|
||||
return times(effect.count, () => ({ type: "draw", player }));
|
||||
case "summon":
|
||||
return times(effect.count, () => ({ type: "summon", card: effect.card, player, at: summonPosition(ctx) }));
|
||||
case "gainArmor":
|
||||
return [{ type: "gainArmor", player, amount: effect.amount }];
|
||||
case "gainAttack":
|
||||
return targets(effect.target, ctx).map((id) => ({ type: "gainAttack", id, amount: effect.amount }));
|
||||
case "destroy":
|
||||
return [{ type: "destroy", targets: targets(effect.target, ctx) }];
|
||||
case "assemble":
|
||||
return [{ type: "assemble", player, filter: effect.filter }];
|
||||
case "buffHand":
|
||||
return ctx.world
|
||||
.minionsInHand(player)
|
||||
.map((id) => ({ type: "buff", id, attack: effect.attack, health: effect.health }));
|
||||
case "spawnText":
|
||||
throw new Error("spawnText needs a grid: it is a Part III effect");
|
||||
}
|
||||
}
|
||||
|
||||
/** The entities a target names, from where the effect runs. */
|
||||
function targets(target: Target, ctx: Ctx): EntityId[] {
|
||||
const { world } = ctx;
|
||||
const others = (ids: EntityId[]) => ids.filter((id) => id !== ctx.source);
|
||||
switch (target) {
|
||||
case "self":
|
||||
return [ctx.minion()];
|
||||
case "allMinions":
|
||||
return world.minionsInPlay();
|
||||
case "allOtherMinions":
|
||||
return others(world.minionsInPlay());
|
||||
case "allOtherCharacters":
|
||||
return others([world.hero(0), world.hero(1), ...world.minionsInPlay()]);
|
||||
case "enemyHero":
|
||||
return [ctx.enemyHero()];
|
||||
case "chosen":
|
||||
return ctx.target === undefined ? [] : [ctx.target];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a summon goes: to the right of a source still on the board; where a dead
|
||||
* source stood (its Death Event's position); at the right end for a spell.
|
||||
*/
|
||||
function summonPosition(ctx: Ctx): number | undefined {
|
||||
const index = ctx.source === undefined ? undefined : ctx.world.boardIndex(ctx.source);
|
||||
return index === undefined ? ctx.position : index + 1;
|
||||
}
|
||||
@@ -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,44 @@
|
||||
// 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.
|
||||
//
|
||||
// Module 5: behaviour as data. Cards are definitions in the course's card format,
|
||||
// loaded from JSON; one interpreter turns their effects into actions. Assemble
|
||||
// builds a new card by swapping half of one card's text for half of another's.
|
||||
//
|
||||
// Part II lives in `puzzle/`: a second engine, for the rule-writing puzzle game,
|
||||
// beside the first.
|
||||
// Module 6: the grid, the lexer and the parser.
|
||||
// Module 7: `IS NOT`, and the resolution pass that turns parsed rules into rules in force.
|
||||
|
||||
export * as puzzle from "./puzzle";
|
||||
export { AssembleError, assemble, registerAssembled, type Half } from "./assemble";
|
||||
export {
|
||||
CardLibrary,
|
||||
triggerLabel,
|
||||
type Aura,
|
||||
type Card,
|
||||
type CardDef,
|
||||
type Clause,
|
||||
type Effect,
|
||||
type PoolFilter,
|
||||
type Target,
|
||||
type Trigger,
|
||||
type TriggerOn,
|
||||
} from "./card";
|
||||
export type { Event } from "./event";
|
||||
export { Ctx, runSequence, summon, type Action } from "./queue";
|
||||
export { HERO_HEALTH, World, type Chooser, type EntityId, type PlayerId, type Zone } from "./world";
|
||||
@@ -0,0 +1,74 @@
|
||||
// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of
|
||||
// objects may share a tile.
|
||||
|
||||
export type ObjectId = number;
|
||||
|
||||
/** A level in the course's level format (`course/schema/level.schema.json`). */
|
||||
export interface LevelDef {
|
||||
width: number;
|
||||
height: number;
|
||||
objects: LevelObject[];
|
||||
}
|
||||
|
||||
export interface LevelObject {
|
||||
kind: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an object is: a thing named by a noun (kind `"<noun>"`, the noun in lower case),
|
||||
* or a piece of text carrying one word (kind `"text:<WORD>"`).
|
||||
*/
|
||||
export type Kind = { type: "thing"; noun: string } | { type: "text"; word: string };
|
||||
|
||||
const TEXT_PREFIX = "text:";
|
||||
|
||||
export const Kind = {
|
||||
parse(kind: string): Kind {
|
||||
return kind.startsWith(TEXT_PREFIX)
|
||||
? { type: "text", word: kind.slice(TEXT_PREFIX.length).toUpperCase() }
|
||||
: { type: "thing", noun: kind.toUpperCase() };
|
||||
},
|
||||
};
|
||||
|
||||
export interface Obj {
|
||||
id: ObjectId;
|
||||
kind: Kind;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class Board {
|
||||
private readonly objectsById = new Map<ObjectId, Obj>();
|
||||
private nextId = 0;
|
||||
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
) {}
|
||||
|
||||
inBounds(x: number, y: number): boolean {
|
||||
return x >= 0 && x < this.width && y >= 0 && y < this.height;
|
||||
}
|
||||
|
||||
/** Add an object; ids start at 1 and are never reused. Throws off the board. */
|
||||
place(kind: Kind, x: number, y: number): ObjectId {
|
||||
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
|
||||
const id = ++this.nextId;
|
||||
this.objectsById.set(id, { id, kind, x, y });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The object `id`. Throws if there is none. */
|
||||
get(id: ObjectId): Obj {
|
||||
const o = this.objectsById.get(id);
|
||||
if (o === undefined) throw new Error(`no object ${id}`);
|
||||
return o;
|
||||
}
|
||||
|
||||
/** Every object, oldest first. */
|
||||
objects(): Obj[] {
|
||||
return [...this.objectsById.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Part II: the rule-writing puzzle game. Objects have no behaviour of their own; every rule is
|
||||
// compiled from the text on the board, and recompiled whenever the board changes.
|
||||
//
|
||||
// Module 6: the grid, the lexer (text objects to words with grammatical roles)
|
||||
// and the parser (words to rules). The engine knows the operators `IS`, `AND`,
|
||||
// `NOT`, the meta-noun `TEXT` and the base rule `TEXT IS PUSH`. Every other word,
|
||||
// nouns included, is data handed in by content.
|
||||
//
|
||||
// Module 7: `IS NOT` in the parser, and the resolution pass (negation, then the
|
||||
// identity lock) that turns the parsed rules into the rules in force. Objects are
|
||||
// asked about through `Level.hasProperty`, which knows which nouns name them.
|
||||
|
||||
export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
|
||||
export { Level } from "./level";
|
||||
export { BASE_RULES, isConversion, isIdentity, negates, parse, ruleText, type Rule } from "./parse";
|
||||
export { resolve } from "./resolve";
|
||||
export { lex, Lexicon, type Role, type Selects, type Token, type WordDef } from "./word";
|
||||
@@ -0,0 +1,53 @@
|
||||
// A level in play: the board, the words content provides, and the rules compiled
|
||||
// from them. Every change to the board recompiles the rules from scratch: parse,
|
||||
// then resolve. Every question about what an object is goes through `hasProperty`.
|
||||
|
||||
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
|
||||
import { parse, type Rule } from "./parse";
|
||||
import { resolve } from "./resolve";
|
||||
import { Lexicon, type WordDef } from "./word";
|
||||
|
||||
export class Level {
|
||||
readonly board: Board;
|
||||
private readonly lexicon: Lexicon;
|
||||
private compiled: Rule[] = [];
|
||||
private active: Rule[] = [];
|
||||
|
||||
constructor(def: LevelDef, words: readonly WordDef[]) {
|
||||
this.board = new Board(def.width, def.height);
|
||||
this.lexicon = new Lexicon(words);
|
||||
for (const o of def.objects) this.board.place(Kind.parse(o.kind), o.x, o.y);
|
||||
this.reparse();
|
||||
}
|
||||
|
||||
place(kind: string, x: number, y: number): ObjectId {
|
||||
const id = this.board.place(Kind.parse(kind), x, y);
|
||||
this.reparse();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Every rule the text spells, as parsed: negated rules and duplicates included. */
|
||||
rules(): readonly Rule[] {
|
||||
return this.compiled;
|
||||
}
|
||||
|
||||
/** The rules in force after the resolution pass. */
|
||||
activeRules(): readonly Rule[] {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does object `id` have `property`? Yes if an active rule gives it to a noun
|
||||
* that names the object and no active negated rule takes it from one.
|
||||
*/
|
||||
hasProperty(id: ObjectId, property: string): boolean {
|
||||
const kind = this.board.get(id).kind;
|
||||
const aboutIt = this.active.filter((r) => r.complement === property && this.lexicon.names(r.subject, kind));
|
||||
return aboutIt.some((r) => !r.negated) && !aboutIt.some((r) => r.negated);
|
||||
}
|
||||
|
||||
private reparse(): void {
|
||||
this.compiled = parse(this.board, this.lexicon);
|
||||
this.active = resolve(this.compiled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// The parser: words on the grid become rules.
|
||||
//
|
||||
// Grammar (Module 7): `NOUN [AND NOUN]* IS [NOT] (NOUN|PROPERTY) [AND [NOT] (NOUN|PROPERTY)]*`,
|
||||
// read left to right in a row and top to bottom in a column. The parser starts at
|
||||
// every `IS`, collects the subjects before it and the complements after it, and
|
||||
// emits one rule per pair: AND distributes over both sides. A tile holding several
|
||||
// words contributes each of them, so stacked text yields every possible rule.
|
||||
//
|
||||
// `NOT` before a complement negates that one complement. `NOT` before a subject is
|
||||
// not in the grammar: the run of subjects stops there and that subject is dropped.
|
||||
//
|
||||
// The parser only reads; which rules win is the resolution pass (`resolve`).
|
||||
|
||||
import type { Board, ObjectId } from "./grid";
|
||||
import { AND, IS, lex, NOT, TEXT, tileKey, type Lexicon, type Role, type Token } from "./word";
|
||||
|
||||
/**
|
||||
* One rule: `subject IS complement`, or `subject IS NOT complement` when `negated`.
|
||||
* `text` is the text objects that spell it (subject, `IS`, `NOT` if any,
|
||||
* complement), or empty for a base rule.
|
||||
*/
|
||||
export interface Rule {
|
||||
subject: string;
|
||||
complement: string;
|
||||
complementRole: Role;
|
||||
negated: boolean;
|
||||
text: ObjectId[];
|
||||
}
|
||||
|
||||
/** The normalised form: `"TEXT IS PUSH"`, `"TEXT IS NOT PUSH"`. */
|
||||
export const ruleText = (r: Rule): string => `${r.subject} ${IS}${r.negated ? ` ${NOT}` : ""} ${r.complement}`;
|
||||
|
||||
/** `X IS Y` where `Y` is a noun: every X is to become a Y. */
|
||||
export const isConversion = (r: Rule): boolean => r.complementRole === "noun";
|
||||
|
||||
/** `X IS X` (or `X IS NOT X`). */
|
||||
export const isIdentity = (r: Rule): boolean => r.subject === r.complement;
|
||||
|
||||
/** Same subject and complement, opposite sign: `X IS NOT Y` negates `X IS Y`. */
|
||||
export const negates = (n: Rule, r: Rule): boolean =>
|
||||
n.negated && !r.negated && n.subject === r.subject && n.complement === r.complement;
|
||||
|
||||
/** Rules in force with no text: the game's base rules (`[subject, property]`). */
|
||||
export const BASE_RULES: readonly (readonly [string, string])[] = [[TEXT, "PUSH"]];
|
||||
|
||||
type Tiles = Map<string, Token[]>;
|
||||
type Pos = readonly [number, number];
|
||||
|
||||
/** A word in a run, with the `NOT` in front of it if there is one. */
|
||||
interface Word {
|
||||
not?: Token;
|
||||
token: Token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule the board spells, base rules first, negated rules included. A rule
|
||||
* spelled by two different sets of text objects appears twice.
|
||||
*/
|
||||
export function parse(board: Board, lexicon: Lexicon): Rule[] {
|
||||
const tiles = lex(board, lexicon);
|
||||
const rules: Rule[] = BASE_RULES.map(([subject, property]) => ({
|
||||
subject,
|
||||
complement: property,
|
||||
complementRole: "property",
|
||||
negated: false,
|
||||
text: [],
|
||||
}));
|
||||
for (const o of board.objects()) {
|
||||
if (o.kind.type !== "text" || o.kind.word !== IS) continue;
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
] as const) {
|
||||
const ss = subjects(tiles, [o.x - dx, o.y - dy], [-dx, -dy]);
|
||||
const cs = complements(tiles, [o.x + dx, o.y + dy], [dx, dy]);
|
||||
for (const s of ss) {
|
||||
for (const c of cs) {
|
||||
rules.push({
|
||||
subject: s.word,
|
||||
complement: c.token.word,
|
||||
complementRole: c.token.role,
|
||||
negated: c.not !== undefined,
|
||||
text: c.not ? [s.id, o.id, c.not.id, c.token.id] : [s.id, o.id, c.token.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
const wordsAt = (tiles: Tiles, [x, y]: Pos): Token[] => tiles.get(tileKey(x, y)) ?? [];
|
||||
const holds = (tiles: Tiles, p: Pos, word: string): boolean => wordsAt(tiles, p).some((t) => t.word === word);
|
||||
const ahead = ([x, y]: Pos, [dx, dy]: Pos): Pos => [x + dx, y + dy];
|
||||
|
||||
/**
|
||||
* The run of subjects before an `IS`, walking backwards from `at`: the nouns at
|
||||
* `at`, then, if the tile before them holds an AND, the run beyond it. A `NOT`
|
||||
* before a noun ends the run without it (a negated subject is not in the grammar).
|
||||
*/
|
||||
function subjects(tiles: Tiles, at: Pos, step: Pos): Token[] {
|
||||
const before = ahead(at, step);
|
||||
if (holds(tiles, before, NOT)) return [];
|
||||
const found = wordsAt(tiles, at).filter((t) => t.role === "noun");
|
||||
if (found.length > 0 && holds(tiles, before, AND)) found.push(...subjects(tiles, ahead(before, step), step));
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The run of complements after an `IS`, walking forwards from `at`: the nouns
|
||||
* and properties at `at`, or, if `at` holds a `NOT`, those on the tile after it,
|
||||
* negated; then, if the next tile holds an AND, the run beyond it.
|
||||
*/
|
||||
function complements(tiles: Tiles, at: Pos, step: Pos): Word[] {
|
||||
const fits = (t: Token) => t.role === "noun" || t.role === "property";
|
||||
const found: Word[] = [];
|
||||
// Plain words here, and negated words one tile on: a stack may hold both.
|
||||
const branches: [Token | undefined, Pos][] = [[undefined, at]];
|
||||
for (const not of wordsAt(tiles, at).filter((t) => t.word === NOT)) branches.push([not, ahead(at, step)]);
|
||||
for (const [not, p] of branches) {
|
||||
const words = wordsAt(tiles, p)
|
||||
.filter(fits)
|
||||
.map((token) => ({ not, token }));
|
||||
if (words.length === 0) continue;
|
||||
found.push(...words);
|
||||
const andAt = ahead(p, step);
|
||||
if (holds(tiles, andAt, AND)) found.push(...complements(tiles, ahead(andAt, step), step));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The resolution pass: from every rule the text spells to the rules in force.
|
||||
//
|
||||
// The parser reports what is written; this pass decides what wins, over the whole
|
||||
// rule list at once, so it doesn't matter where on the board each rule is written:
|
||||
//
|
||||
// 1. Negation. `X IS NOT Y` cancels every `X IS Y`. The negated rule stays in
|
||||
// force: it keeps overriding, and `X IS NOT X` is also the record that every X is
|
||||
// to vanish (the turn removes them, Module 9).
|
||||
// 2. Identity. A surviving `X IS X` cancels every conversion `X IS Y` (`Y` a noun):
|
||||
// X can't become anything else. It doesn't stop other nouns becoming X. Because
|
||||
// negation runs first, `X IS NOT X` cancels `X IS X` and conversions work again.
|
||||
//
|
||||
// Base rules are rules like any other, so `TEXT IS NOT PUSH` cancels `TEXT IS PUSH`.
|
||||
|
||||
import { isConversion, isIdentity, negates, type Rule } from "./parse";
|
||||
|
||||
/** The rules in force, in the order they were parsed. */
|
||||
export function resolve(rules: readonly Rule[]): Rule[] {
|
||||
const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r)));
|
||||
const locked = (subject: string) =>
|
||||
survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject);
|
||||
return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// The lexer: text objects become words with a grammatical role.
|
||||
//
|
||||
// The engine owns the grammar words (`IS`, `AND`, `NOT`) and the meta-noun `TEXT`.
|
||||
// Every other word comes from content as data: the engine never names a noun.
|
||||
// A noun also says which objects it names (`Selects`), so a meta-noun that names
|
||||
// a whole group of objects can be data too.
|
||||
|
||||
import type { Board, Kind, ObjectId } from "./grid";
|
||||
|
||||
export type Role = "noun" | "property" | "operator";
|
||||
|
||||
/**
|
||||
* Which objects a noun names when it is the subject of a rule:
|
||||
* - `"named"` (the default): the things of its own name, `<NOUN>` names every object of kind `"<noun>"`;
|
||||
* - `"text"`: every text object (the meta-noun `TEXT`);
|
||||
* - `"things"`: every object that is not text, whatever its noun.
|
||||
*/
|
||||
export type Selects = "named" | "text" | "things";
|
||||
|
||||
/**
|
||||
* One entry of the word list content provides: `{ "name": "<NOUN>", "role": "noun" }`,
|
||||
* optionally with `"selects"` (default `"named"`).
|
||||
*/
|
||||
export interface WordDef {
|
||||
name: string;
|
||||
role: Role;
|
||||
selects?: Selects;
|
||||
}
|
||||
|
||||
export const IS = "IS";
|
||||
export const AND = "AND";
|
||||
export const NOT = "NOT";
|
||||
/** The meta-noun for every text object. */
|
||||
export const TEXT = "TEXT";
|
||||
|
||||
/** Every word the parser can read, with its role. */
|
||||
export class Lexicon {
|
||||
private readonly roles = new Map<string, Role>();
|
||||
private readonly selects = new Map<string, Selects>();
|
||||
|
||||
/** The engine's own words plus `words`. Content can't redefine an engine word. */
|
||||
constructor(words: readonly WordDef[]) {
|
||||
for (const w of words) {
|
||||
this.roles.set(w.name.toUpperCase(), w.role);
|
||||
this.selects.set(w.name.toUpperCase(), w.selects ?? "named");
|
||||
}
|
||||
for (const op of [IS, AND, NOT]) this.roles.set(op, "operator");
|
||||
this.roles.set(TEXT, "noun");
|
||||
this.selects.set(TEXT, "text");
|
||||
}
|
||||
|
||||
/** `undefined` for a word the lexicon doesn't know: such text spells nothing. */
|
||||
role(word: string): Role | undefined {
|
||||
return this.roles.get(word);
|
||||
}
|
||||
|
||||
/** Does the noun `word`, as the subject of a rule, name an object of this kind? */
|
||||
names(word: string, kind: Kind): boolean {
|
||||
switch (this.selects.get(word) ?? "named") {
|
||||
case "named":
|
||||
return kind.type === "thing" && kind.noun === word;
|
||||
case "text":
|
||||
return kind.type === "text";
|
||||
case "things":
|
||||
return kind.type === "thing";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A piece of text the parser can read: which object, which word, which role. */
|
||||
export interface Token {
|
||||
id: ObjectId;
|
||||
word: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/** Tile id for maps: `"x,y"`. */
|
||||
export const tileKey = (x: number, y: number): string => `${x},${y}`;
|
||||
|
||||
/**
|
||||
* The words on the board, by tile; a tile's words in object order (oldest first).
|
||||
* Text whose word the lexicon doesn't know is left out.
|
||||
*/
|
||||
export function lex(board: Board, lexicon: Lexicon): Map<string, Token[]> {
|
||||
const tiles = new Map<string, Token[]>();
|
||||
for (const o of board.objects()) {
|
||||
if (o.kind.type !== "text") continue;
|
||||
const role = lexicon.role(o.kind.word);
|
||||
if (role === undefined) continue;
|
||||
const tile = tileKey(o.x, o.y);
|
||||
const tokens = tiles.get(tile) ?? [];
|
||||
tokens.push({ id: o.id, word: o.kind.word, role });
|
||||
tiles.set(tile, tokens);
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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.
|
||||
|
||||
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 };
|
||||
|
||||
/** 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? event.position : world.boardIndex(source);
|
||||
runEffects(world, trigger.effects, new Ctx(world, world.ownerOf(source), source, undefined, position));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 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, 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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user