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>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-11 11:20:03 +01:00
1 parent 21fb40c6f5
commit 820b378929
27 files changed
+530 -30

No files matched your search

@@ -54,6 +54,12 @@ export interface Obj {
y: number;
}
/** What `Board.restore` puts back (Module 9, undo): every object, oldest first, and the id counter. */
export interface BoardState {
objects: Obj[];
nextId: ObjectId;
}
export class Board {
private readonly objectsById = new Map<ObjectId, Obj>();
private nextId = 0;
@@ -124,4 +130,16 @@ export class Board {
objects(): Obj[] {
return [...this.objectsById.values()];
}
/** A copy of everything the board holds (Module 9, undo). */
snapshot(): BoardState {
return { objects: this.objects().map((o) => ({ ...o })), nextId: this.nextId };
}
/** Put the board back as `state` recorded it: the same objects, ids, ages and id counter. */
restore(state: BoardState): void {
this.objectsById.clear();
for (const o of state.objects) this.objectsById.set(o.id, { ...o });
this.nextId = state.nextId;
}
}
@@ -8,11 +8,16 @@
// 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.
//
// Module 10: what happens to the objects a turn destroys is a `Destroy` the caller
// may pass in. The default removes them. SpellGrid passes one that sends minions to
// Part I's Death Creation Step instead.
// Part I's Death Creation Step instead. SpellGrid doesn't offer undo: Part I has
// state the level's snapshot doesn't cover.
import { Board, Kind, type LevelDef, type ObjectId } from "./grid";
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";
@@ -46,6 +51,8 @@ export class Level {
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
@@ -65,6 +72,7 @@ export class Level {
place(kind: string, x: number, y: number): ObjectId {
const id = this.board.place(Kind.parse(kind), x, y);
this.history = [];
this.reparse();
return id;
}
@@ -125,12 +133,14 @@ export class Level {
* only: no conversion, hazard or win.
*/
moveYou(dir: Dir, destroy: Destroy = removeAll): void {
this.save();
this.movement(dir, destroy);
this.reparse();
}
/** Play one turn: the phases of `TURN`, in order. Destroyed objects go to `destroy`. */
step(dir: Dir, destroy: Destroy = removeAll): void {
this.save();
for (const phase of TURN) {
switch (phase) {
case "movement":
@@ -152,6 +162,23 @@ export class Level {
}
}
/**
* 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,
@@ -159,10 +159,12 @@ export interface Baba {
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. */
@@ -186,9 +188,14 @@ export function newBaba(level: puzzle.LevelDef): Baba {
.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(),
};
}
@@ -254,9 +261,17 @@ export function newSpellGrid(level: puzzle.LevelDef, extra: CardDef[]): SpellGri
.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),