// 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(); 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); } }