feat: module 08 push chains

Movement planned in full, then committed: a Planner builds a plan
(moving and destroyed ids) by running content's movement responses
(OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches.
Edge of level blocks; YOU objects move furthest-ahead first; rules are
reparsed once after movement. Harness: move/move_dir(Dir), posOf,
kindsAt. Expansion Drop: PULL as one word plus one content response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 04:05:42 +01:00
co-authored by Claude Opus 5.5
parent d993457772
commit bd80cefbef
64 changed files with 6933 additions and 0 deletions
@@ -0,0 +1,34 @@
/// <reference types="vite/client" />
// Content: the cards and the Baba Is You words. Only this directory (and the card
// data in `cards/`) may name specific cards, or specific Baba Is You nouns.
//
// From Module 5 every card is a JSON file in `cards/` at the package root, in the
// course's card format (`course/schema/card.schema.json`). The functions that were
// card effects in Modules 2–4 are gone: the engine interprets the data.
// `start-module` copies the course's card files into `cards/`; add your own there.
//
// `import.meta.glob` is Vite's (Vitest runs on Vite): it imports every matching
// JSON file when this module loads, so no file system code is needed.
import type { CardDef, puzzle } from "../engine";
import wordList from "./words.json";
export { responses as movement } from "./movement";
const files = import.meta.glob<CardDef>("../../cards/*.json", { eager: true, import: "default" });
/** Every card in `cards/`, in file-name order. */
export function cards(): CardDef[] {
return Object.keys(files)
.sort()
.map((path) => files[path]);
}
/**
* Part II: the words of Baba Is You the parser can read, besides the engine's own
* (`IS`, `AND`, `NOT`, `TEXT`). Nouns and properties are data: a new word is one
* more entry in `words.json` in this directory.
*/
export function words(): puzzle.WordDef[] {
return wordList as puzzle.WordDef[];
}
@@ -0,0 +1,45 @@
// Part II: what each property does when an object moves. The engine plans a move
// one step at a time and runs these responses for every step, in this order (the
// order of the wiki's movement checks: OPEN/SHUT, then PUSH, then STOP). A new
// movement property is one more response here.
import type { puzzle } from "../engine";
type Planner = puzzle.Planner;
type Step = puzzle.Step;
/**
* An OPEN object stepping onto a SHUT one (or a SHUT one onto an OPEN one)
* destroys both, and that object doesn't block the step. One partner per step:
* the oldest.
*/
function openShut(p: Planner, s: Step): boolean {
for (const [mine, theirs] of [
["OPEN", "SHUT"],
["SHUT", "OPEN"],
]) {
if (!p.has(s.mover, mine)) continue;
const partner = p.stayingAt(s.to).find((o) => p.has(o, theirs));
if (partner !== undefined) {
p.destroy(partner);
p.destroy(s.mover);
return true;
}
}
return true;
}
/** Every PUSH object on the tile ahead must step the same way, or the step fails. */
function push(p: Planner, s: Step): boolean {
return p.stayingAt(s.to).every((o) => !p.has(o, "PUSH") || p.tryMove(o));
}
/** A STOP object still on the tile ahead blocks the step. */
function stop(p: Planner, s: Step): boolean {
return !p.stayingAt(s.to).some((o) => p.has(o, "STOP"));
}
/** The movement responses, in the order the engine runs them. */
export function responses(): puzzle.Response[] {
return [openShut, push, stop];
}
@@ -0,0 +1,17 @@
[
{ "name": "BABA", "role": "noun" },
{ "name": "ROCK", "role": "noun" },
{ "name": "WALL", "role": "noun" },
{ "name": "FLAG", "role": "noun" },
{ "name": "LAVA", "role": "noun" },
{ "name": "KEY", "role": "noun" },
{ "name": "DOOR", "role": "noun" },
{ "name": "SKULL", "role": "noun" },
{ "name": "WATER", "role": "noun" },
{ "name": "YOU", "role": "property" },
{ "name": "WIN", "role": "property" },
{ "name": "PUSH", "role": "property" },
{ "name": "STOP", "role": "property" },
{ "name": "OPEN", "role": "property" },
{ "name": "SHUT", "role": "property" }
]
@@ -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,45 @@
// 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.
// Module 8: movement, planned in full and then committed; push chains.
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,103 @@
// 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() };
},
/** The kind as it is placed: `"<noun>"` in lower case, or `"text:<WORD>"`. */
format(kind: Kind): string {
return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase();
},
};
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;
}
/** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */
find(id: ObjectId): Obj | undefined {
if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`);
return this.objectsById.get(id);
}
/** Take object `id` off the board for good. */
remove(id: ObjectId): void {
this.objectsById.delete(id);
}
/** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */
shift(id: ObjectId, [dx, dy]: readonly [number, number]): void {
const o = this.get(id);
const [x, y] = [o.x + dx, o.y + dy];
if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`);
o.x = x;
o.y = y;
}
/** The objects on tile (x, y), oldest first. */
at(x: number, y: number): Obj[] {
return this.objects().filter((o) => o.x === x && o.y === y);
}
/** Every object, oldest first. */
objects(): Obj[] {
return [...this.objectsById.values()];
}
}
@@ -0,0 +1,22 @@
// 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.
//
// Module 8: movement. `Level.moveYou` plans each `YOU` object's move in full
// (`Planner`), then commits it, then reparses. What a property does when something
// moves is a `Response` that content provides; the engine runs them in order.
export { Board, Kind, type LevelDef, type LevelObject, type Obj, type ObjectId } from "./grid";
export { Level, YOU } from "./level";
export { delta, Planner, type Dir, type MoveContext, type Plan, type Pos, type Response, type Step } from "./movement";
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,103 @@
// 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`.
//
// A move (Module 8) is planned for each `YOU` object, then committed; the rules are
// reparsed once, after all of them.
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
import { delta, Planner, type Dir, type Plan, type Response } from "./movement";
import { parse, type Rule } from "./parse";
import { resolve } from "./resolve";
import { Lexicon, type WordDef } from "./word";
/** The property that makes an object move when the player moves. */
export const YOU = "YOU";
export class Level {
readonly board: Board;
private readonly lexicon: Lexicon;
private compiled: Rule[] = [];
private active: Rule[] = [];
/**
* A level from its definition, the words content provides, and content's
* movement responses, in the order they are to run.
*/
constructor(
def: LevelDef,
words: readonly WordDef[],
private readonly moveResponses: readonly Response[],
) {
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;
}
/** The movement responses, in the order they run. */
responses(): readonly Response[] {
return this.moveResponses;
}
/** 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. A
* destroyed object has no properties.
*/
hasProperty(id: ObjectId, property: string): boolean {
const object = this.board.find(id);
if (object === undefined) return false;
const kind = object.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);
}
/**
* Move every `YOU` object one tile in `dir`, then reparse. The `YOU` objects
* move one at a time, the one furthest ahead in `dir` first (oldest first on a
* tie); each one's move is planned in full, then committed, before the next is
* planned. Every move uses the rules in force before the first.
*/
moveYou(dir: Dir): void {
const [dx, dy] = delta(dir);
const ahead = (o: { x: number; y: number }) => o.x * dx + o.y * dy;
const you = this.board
.objects()
.filter((o) => this.hasProperty(o.id, YOU))
.sort((a, b) => ahead(b) - ahead(a) || a.id - b.id)
.map((o) => o.id);
for (const id of you) {
if (this.board.find(id) === undefined) continue; // destroyed by an earlier move
const planner = new Planner(this, dir);
if (planner.tryMove(id)) this.commit(planner.result(), dir);
}
this.reparse();
}
private commit(plan: Plan, dir: Dir): void {
for (const id of plan.destroyed) this.board.remove(id);
for (const id of plan.moving) if (!plan.destroyed.has(id)) this.board.shift(id, delta(dir));
}
private reparse(): void {
this.compiled = parse(this.board, this.lexicon);
this.active = resolve(this.compiled);
}
}
@@ -0,0 +1,121 @@
// Movement: validate, then commit.
//
// A move is planned before anything on the board changes. The `Planner` asks
// whether one object can step one tile; to answer, it runs every movement
// response content provides for that step, and a response may ask the planner
// to move other objects too (a push), or to destroy objects. If any response
// says no, the planner forgets everything that step added to the plan, so a
// failed push leaves no trace. Only a plan that succeeded is committed.
//
// The engine knows the board, its edge, and the order of the steps. What each
// property does when something moves (`PUSH`, `STOP`, `OPEN`/`SHUT`, ...) is a
// response in content, run in the order content lists them.
import type { Board, ObjectId } from "./grid";
/** One of the four directions a move can take. */
export type Dir = "up" | "down" | "left" | "right";
export type Pos = readonly [number, number];
/** One tile in direction `dir`, as `[dx, dy]`; `y` grows down. */
export function delta(dir: Dir): Pos {
switch (dir) {
case "up":
return [0, -1];
case "down":
return [0, 1];
case "left":
return [-1, 0];
case "right":
return [1, 0];
}
}
/** One object stepping one tile, from `from` to `to`, in direction `dir`. */
export interface Step {
mover: ObjectId;
from: Pos;
to: Pos;
dir: Dir;
}
/**
* A movement response: content's rule for what happens when an object steps.
* It returns `false` to block the step. It may plan other moves or destroy
* objects through the planner.
*/
export type Response = (p: Planner, s: Step) => boolean;
/**
* What a move will do: the objects that step one tile, and the objects destroyed.
* A destroyed object doesn't step, even if it was pushed.
*/
export interface Plan {
moving: Set<ObjectId>;
destroyed: Set<ObjectId>;
}
/** What the planner reads: the board, the properties in force, the responses. */
export interface MoveContext {
readonly board: Board;
hasProperty(id: ObjectId, property: string): boolean;
responses(): readonly Response[];
}
/** Plans one move on a board it only reads. */
export class Planner {
private plan: Plan = { moving: new Set(), destroyed: new Set() };
constructor(
private readonly level: MoveContext,
private readonly dir: Dir,
) {}
/** Does object `id` have `property`, under the rules in force before the move? */
has(id: ObjectId, property: string): boolean {
return this.level.hasProperty(id, property);
}
/**
* The objects that will still be on tile `at` when the plan so far is
* committed: those there now, less those planned to move away or be destroyed.
* Oldest first.
*/
stayingAt([x, y]: Pos): ObjectId[] {
return this.level.board
.at(x, y)
.map((o) => o.id)
.filter((id) => !this.plan.moving.has(id) && !this.plan.destroyed.has(id));
}
/** Plan object `id`'s destruction. */
destroy(id: ObjectId): void {
this.plan.destroyed.add(id);
}
/**
* Plan object `id` stepping one tile in the move's direction, with whatever
* the responses add. Returns whether it can. If it can't, the plan is left as
* it was before the call. An object already planned to move, or to be
* destroyed, is out of the way: the answer is yes, and nothing is added.
*/
tryMove(id: ObjectId): boolean {
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
const o = this.level.board.get(id);
const [dx, dy] = delta(this.dir);
const to: Pos = [o.x + dx, o.y + dy];
// The edge of the level blocks every step (the course's choice; see the lesson).
if (!this.level.board.inBounds(...to)) return false;
const before: Plan = { moving: new Set(this.plan.moving), destroyed: new Set(this.plan.destroyed) };
this.plan.moving.add(id);
const step: Step = { mover: id, from: [o.x, o.y], to, dir: this.dir };
const allowed = this.level.responses().every((respond) => respond(this, step));
if (!allowed) this.plan = before;
return allowed;
}
result(): Plan {
return this.plan;
}
}
@@ -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 });
}
}
@@ -0,0 +1,158 @@
// Test harness: the thin adapter the course tests call. It translates each
// call into engine operations and holds no rules of its own.
import { cards, movement, words } from "./content";
import {
assemble,
CardLibrary,
puzzle,
registerAssembled,
runSequence,
summon,
triggerLabel,
World,
type CardDef,
type Chooser,
type Event,
type Half,
type Zone,
} from "./engine";
export { AssembleError } from "./engine";
export type { CardDef, Chooser, Half, 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
// Module 5
registerCards(defs: CardDef[]): void; // add card definitions, replacing any with the same name
assemble(base: string, donor: string, half: Half): string; // registers "<base> (Assembled)"; throws AssembleError
cost(id: Id): number;
setChooser(choose: Chooser): void; // makes Assemble's choices: kind "base" | "donor" | "half"
}
export function newGame(opts?: { decks?: [string[], string[]] }): Game {
const world = new World(new CardLibrary(cards()));
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] }),
registerCards: (defs) => defs.forEach((def) => world.register(def)),
assemble: (base, donor, half) =>
registerAssembled(world, assemble(world.card(base).def, world.card(donor).def, half)),
cost: (id) => world.cost(id),
setChooser: (choose) => world.setChooser(choose),
};
}
// ======== Part II: Baba Is You ========
export type { Dir, LevelDef, LevelObject } from "./engine/puzzle";
/** A Baba Is You level in play (Part II). */
export interface Baba {
// Module 6
place(kind: string, x: number, y: number): Id; // "baba" or "text:BABA"
rules(): string[]; // distinct, normalised ("BABA IS YOU", "BABA IS NOT YOU"), sorted; before resolution
// Module 7
activeRules(): string[]; // the same, after resolution (NOT, identity)
hasProperty(id: Id, prop: string): boolean;
// Module 8
move(dir: puzzle.Dir): void; // every YOU object one tile, with what it pushes; then reparse. Movement only.
posOf(id: Id): [number, number] | null; // null once destroyed
kindsAt(x: number, y: number): string[]; // kinds as placed ("baba", "text:ROCK"), sorted
}
/** Distinct rules as normalised strings, sorted. */
const normalised = (rules: readonly puzzle.Rule[]): string[] => [...new Set(rules.map(puzzle.ruleText))].sort();
/** A level from a level definition; its text is parsed straight away. */
export function newBaba(level: puzzle.LevelDef): Baba {
const lvl = new puzzle.Level(level, words(), movement());
return {
place: (kind, x, y) => lvl.place(kind, x, y),
rules: () => normalised(lvl.rules()),
activeRules: () => normalised(lvl.activeRules()),
hasProperty: (id, prop) => lvl.hasProperty(id, prop),
move: (dir) => lvl.moveYou(dir),
posOf: (id) => {
const o = lvl.board.find(id);
return o === undefined ? null : [o.x, o.y];
},
kindsAt: (x, y) =>
lvl.board
.at(x, y)
.map((o) => puzzle.Kind.format(o.kind))
.sort(),
};
}