Files
systemic-game-design-course/course/11-conditions/reference/ts/src/harness.ts
T
Lucas WintherandClaude Opus 5.5 34c655c1a4 feat: Module 11, Conditions (NOUN ON NOUN IS ..., checked per object at use; NEAR as the Expansion Drop)
Adds course/11-conditions: lesson, scenarios, nine test_11 tests in both
tracks, the cumulative reference (Module 10's plus conditions) and the NEAR
drop. The engine learns a condition role, rules carry conditions, and
Level.hasProperty and conversion evaluate content's condition checks against
the board whenever a rule is used. Resolution pairs only rules with the same
conditions. Catalog, harness contract, research notes and the diff-tracks
generator follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:00:26 +01:00

302 lines
12 KiB
TypeScript

// 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, conditions, hazards, movement, seams, words } from "./content";
import {
assemble,
CardLibrary,
END_OF_PHASE,
SpellGrid as Grid,
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: "ArmorLost"; 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 };
if (event.type === "Summoned") return { type: "Summoned", id: event.id };
// A played minion's `Summoned` already shows it entering the board.
if (event.type === "Played") return undefined;
// SpellGrid writes the text on the grid; the course's log doesn't show the request.
if (event.type === "TextSpawned") return undefined;
return { ...event };
}
/** One event as `scenarios.md` writes it, such as `Triggered{source: 3, trigger: "deathrattle"}`. */
function eventText(event: GameEvent): string {
const { type, ...fields } = event;
const shown = Object.entries(fields).map(
([key, value]) => `${key}: ${typeof value === "string" ? JSON.stringify(value) : value}`,
);
return `${type}{${shown.join(", ")}}`;
}
/** The log as a tree: one event per line, two spaces of indent per level of nesting. */
function logTree(world: World, view: (event: Event) => GameEvent | undefined): string {
const depths = world.logDepths();
return world
.log()
.map((event, i) => {
const shown = view(event);
return shown === undefined ? "" : `${" ".repeat(depths[i])}${eventText(shown)}\n`;
})
.join("");
}
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;
logTree(): string; // the log as an indented tree
// 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(),
logTree: () => logTree(world, toGameEvent),
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, LevelCard, 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", "BABA ON KEY IS WIN"), 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
stackAt(x: number, y: number): string[]; // the same tile, oldest object first
// Module 9
step(dir: puzzle.Dir): void; // one whole turn: movement, parse, conversion, parse, hazards, win (then parse)
won(): boolean; // a turn found a YOU object on a WIN one (or WIN itself); stays true
lost(): boolean; // no object is YOU
undo(): boolean; // back to before the last step or move; false if there is none
}
/** 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(), hazards(), conditions());
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(),
stackAt: (x, y) =>
lvl.board
.at(x, y)
.map((o) => puzzle.Kind.format(o.kind)),
step: (dir) => lvl.step(dir),
won: () => lvl.won(),
lost: () => lvl.lost(),
undo: () => lvl.undo(),
};
}
// ======== Part III: SpellGrid ========
/**
* A SpellGrid level in play (Part III): a Baba Is You level whose minions are cards.
* Every id is a grid id: a minion's is its grid object's (see `engine/spellgrid.ts`).
*/
export interface SpellGrid extends Baba {
// Module 10
cast(card: string, target?: Id): void; // a spell from player 0's hand, as one Sequence; target: a minion
play(card: string, x: number, y: number): Id; // a minion from player 0's hand onto an empty tile
health(id: Id): number;
hero(player: Player): Id;
nameOf(id: Id): string;
minionAt(x: number, y: number): Id | null;
hand(): string[]; // card names in player 0's hand, in order
log(): GameEvent[]; // Part I's log, with grid ids
clearLog(): void;
logTree(): string; // Part I's log as an indented tree, with grid ids
setChooser(choose: Chooser): void; // makes Assemble's choices
withNaiveAuraTiming(): void; // test hook: Aura Update before the Death Creation Step
// For the command line: why `cast`/`play` would fail, or null if they wouldn't
checkCast(card: string, target?: Id): string | null;
checkPlay(card: string, x: number, y: number): string | null;
}
/** A SpellGrid level; `extra` cards are added to the course's cards, replacing any with the same name. */
export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGrid {
const library = new CardLibrary(cards());
for (const def of extra) library.register(def);
const g = new Grid(level, library, words(), movement(), hazards(), conditions(), seams());
const lvl = g.level;
/** An engine event with every entity id as a grid id. */
const withIds = (event: GameEvent): GameEvent => {
switch (event.type) {
case "Summoned":
case "Destroyed":
case "Drew":
case "StatsChanged":
return { ...event, id: g.idOf(event.id) };
case "Damaged":
return { ...event, target: g.idOf(event.target) };
case "Triggered":
return { ...event, source: g.idOf(event.source) };
default:
return event;
}
};
return {
place: (kind, x, y) => g.place(kind, x, y),
rules: () => normalised(lvl.rules()),
activeRules: () => normalised(lvl.activeRules()),
hasProperty: (id, prop) => lvl.hasProperty(id, prop),
move: (dir) => g.move(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(),
stackAt: (x, y) =>
lvl.board
.at(x, y)
.map((o) => puzzle.Kind.format(o.kind)),
step: (dir) => g.step(dir),
won: () => lvl.won(),
lost: () => lvl.lost(),
// Part I has state (health, hands, the log) that the level's snapshot doesn't cover.
undo: () => {
throw new Error("undo is not supported in SpellGrid");
},
cast: (card, target) => g.cast(card, target),
play: (card, x, y) => g.play(card, x, y),
health: (id) => g.world.health(g.entity(id)),
hero: (player) => g.idOf(g.world.hero(player)),
nameOf: (id) => g.world.cardName(g.entity(id)),
minionAt: (x, y) => g.minionAt(x, y) ?? null,
hand: () => g.world.zone(0, "hand").map((id) => g.world.cardName(id)),
log: () =>
g.world
.log()
.flatMap((event) => toGameEvent(event) ?? [])
.map(withIds),
clearLog: () => g.world.clearLog(),
logTree: () =>
logTree(g.world, (event) => {
const shown = toGameEvent(event);
return shown === undefined ? undefined : withIds(shown);
}),
setChooser: (choose) => g.world.setChooser(choose),
// The naive engine reads the auras before the dead leave play: the same steps, reversed.
withNaiveAuraTiming: () => g.world.setEndOfPhase([...END_OF_PHASE].reverse()),
checkCast: (card, target) => g.checkCast(card, target) ?? null,
checkPlay: (card, x, y) => g.checkPlay(card, x, y) ?? null,
};
}