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>
268 lines
9.6 KiB
TypeScript
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);
|
|
}
|
|
}
|