Files
systemic-game-design-course/course/09-turn-pipeline/reference/ts/src/engine/puzzle/level.ts
T
Lucas WintherandClaude Opus 5.5 820b378929 feat: undo in Module 9 and stackAt from Module 8; Lesson 8 quote matches the reference
Baba.undo() restores the level before the last step or move (board snapshot:
objects in age order with ids, the id counter, and won; rules recompiled).
place clears the history. SpellGrid's undo throws/panics 'not supported'.
stackAt / stack_at lists a tile's kinds oldest first, on Baba and SpellGrid.

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

268 lines
9.6 KiB
TypeScript

// 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.
//
// A turn (Module 9) runs the phases of `TURN` in order: movement, parse,
// conversion, parse, content's hazards, the win check, parse.
//
// Undo (Module 9): before each move or turn the level saves the board (every
// object's id, kind and position, oldest first, and the id counter) and `won`.
// The rules are not saved: undo restores the board and reparses.
import { Board, Kind, type BoardState, type LevelDef, type ObjectId } from "./grid";
import { delta, Planner, type Dir, type Plan, type Response } from "./movement";
import { isConversion, isIdentity, parse, type Rule } from "./parse";
import { resolve } from "./resolve";
import { TURN, type Hazard } from "./turn";
import { Lexicon, type WordDef } from "./word";
/** The property that makes an object move when the player moves. */
export const YOU = "YOU";
/** The property that wins the level when a `YOU` object is on it, or is it. */
export const WIN = "WIN";
/**
* The property that puts an object on the other float level: only objects on
* the same level share a tile for hazards and for winning.
*/
export const FLOAT = "FLOAT";
export class Level {
readonly board: Board;
private readonly lexicon: Lexicon;
private compiled: Rule[] = [];
private active: Rule[] = [];
private hasWon = false;
/** What `undo` puts back: one entry per move or turn, the latest last (Module 9). */
private history: { board: BoardState; won: boolean }[] = [];
/**
* A level from its definition, the words content provides, and content's
* movement responses and hazards, each in the order they are to run.
*/
constructor(
def: LevelDef,
words: readonly WordDef[],
private readonly moveResponses: readonly Response[],
private readonly hazards: readonly Hazard[],
) {
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.history = [];
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);
}
/**
* The other objects on object `id`'s tile that are on its float level (both
* `FLOAT` or both not), oldest first. Hazards and the win check ask this.
*/
sharing(id: ObjectId): ObjectId[] {
const o = this.board.get(id);
const floats = this.hasProperty(id, FLOAT);
return this.board
.at(o.x, o.y)
.filter((other) => other.id !== id && this.hasProperty(other.id, FLOAT) === floats)
.map((other) => other.id);
}
/** Has a turn's win check found a `YOU` object on a `WIN` one? Stays true. */
won(): boolean {
return this.hasWon;
}
/** Is there no `YOU` object left? */
lost(): boolean {
return !this.board.objects().some((o) => this.hasProperty(o.id, YOU));
}
/**
* Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement
* only: no conversion, hazard or win.
*/
moveYou(dir: Dir): void {
this.save();
this.movement(dir);
this.reparse();
}
/** Play one turn: the phases of `TURN`, in order. */
step(dir: Dir): void {
this.save();
for (const phase of TURN) {
switch (phase) {
case "movement":
this.movement(dir);
break;
case "parse":
this.reparse();
break;
case "conversion":
this.convert();
break;
case "hazards":
this.runHazards();
break;
case "win":
this.checkWin();
break;
}
}
}
/**
* Put the level back as it was before the last `moveYou` or `step` (Module 9),
* and reparse. False, and no change, if there is none; `place` forgets them all.
*/
undo(): boolean {
const last = this.history.pop();
if (last === undefined) return false;
this.board.restore(last.board);
this.hasWon = last.won;
this.reparse();
return true;
}
private save(): void {
this.history.push({ board: this.board.snapshot(), won: this.hasWon });
}
/**
* 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.
*
* An object steps at most once in a movement phase. A `YOU` object that an
* earlier move already carried along (pushed, or moved by any other response)
* doesn't start a move of its own, and no later move can make it step again.
*/
private movement(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);
const stepped = new Set<ObjectId>();
for (const id of you) {
if (this.board.find(id) === undefined) continue; // destroyed by an earlier move
if (stepped.has(id)) continue; // already moved, by an earlier move
const planner = new Planner(this, dir, stepped);
if (!planner.tryMove(id)) continue;
const plan = planner.result();
this.commit(plan, dir);
for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved);
}
}
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));
}
/**
* Every object named by a conversion `X IS Y` (`Y` a noun) becomes a `Y` where
* it stands, keeping its id; with several `Y`s it becomes the first, and each
* other `Y` is a new object on its tile. An `X IS X` naming the object blocks
* this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s,
* as new objects: the negation cancelled `X IS X`.
*
* Negation is settled per object, as in `hasProperty`: an object doesn't become
* a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the
* conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X).
*
* Every object is decided from the board as movement left it, before any
* changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys.
*/
private convert(): void {
const changes: { id: ObjectId; x: number; y: number; deleted: boolean; into: Kind[] }[] = [];
for (const o of this.board.objects()) {
const aboutIt = this.active.filter((r) => this.lexicon.names(r.subject, o.kind));
const deleted = aboutIt.some((r) => r.negated && isIdentity(r));
const locked = aboutIt.some((r) => !r.negated && isIdentity(r));
const into: Kind[] = [];
if (!locked) {
for (const r of aboutIt) {
if (r.negated || !isConversion(r)) continue;
if (aboutIt.some((n) => n.negated && n.complement === r.complement)) continue;
const kind = this.lexicon.thingNamed(r.complement);
if (kind === undefined) continue;
const same = (k: Kind) => Kind.format(k) === Kind.format(kind);
if (!same(o.kind) && !into.some(same)) into.push(kind);
}
}
if (deleted || into.length > 0) changes.push({ id: o.id, x: o.x, y: o.y, deleted, into });
}
for (const { id, x, y, deleted, into } of changes) {
if (deleted) this.board.remove(id);
else this.board.setKind(id, into[0]);
for (const kind of deleted ? into : into.slice(1)) this.board.place(kind, x, y);
}
}
/**
* Run content's hazards in order. Each one's victims are destroyed together
* before the next runs; no reparse until the turn's last phase.
*/
private runHazards(): void {
for (const hazard of this.hazards) {
for (const id of hazard(this)) this.board.remove(id);
}
}
/**
* The level is won if any `YOU` object is `WIN`, or shares its tile with a
* `WIN` object on its float level.
*/
private checkWin(): void {
const wins = (id: ObjectId) => this.hasProperty(id, WIN);
const won = this.board
.objects()
.filter((o) => this.hasProperty(o.id, YOU))
.some((o) => wins(o.id) || this.sharing(o.id).some(wins));
this.hasWon ||= won;
}
private reparse(): void {
this.compiled = parse(this.board, this.lexicon);
this.active = resolve(this.compiled);
}
}