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>
302 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|