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

+9 -4
View File
@@ -76,17 +76,19 @@ node tools/start-module.mjs 08 ~/my-engine
### The harness
Three new functions on `Baba`:
Four new functions on `Baba`:
| TypeScript | Rust |
|---|---|
| `move(dir): void` | `move_dir(&mut self, dir: Dir)` (`move` is a Rust keyword) |
| `posOf(id): [number, number] \| null` | `pos_of(&self, id: Id) -> Option<(i32, i32)>` |
| `kindsAt(x, y): string[]` | `kinds_at(&self, x: i32, y: i32) -> Vec<String>` |
| `stackAt(x, y): string[]` | `stack_at(&self, x: i32, y: i32) -> Vec<String>` |
- `move(dir)` moves every `YOU` object one tile, `"up"`, `"down"`, `"left"` or `"right"` (Rust: `Dir::Up`, ...), with everything it pushes, then reparses the rules. Nothing else: no conversion, no hazards, no win. That is Module 9's turn.
- `posOf(id)` is where the object is, or `null` (Rust: `None`) once it has been destroyed.
- `kindsAt(x, y)` lists the kinds on a tile as they were placed (`"baba"`, `"text:ROCK"`), sorted.
- `stackAt(x, y)` lists the same kinds, oldest object first. An object's age is when it was placed, not when it arrived on the tile. The tie-breaks in this module (which YOU object moves first, which SHUT partner goes) follow age, and `kindsAt` can't show it.
In Rust, `Dir` is an enum with the variants `Up`, `Down`, `Left`, `Right`, exported from your harness. `course/harness.md` has its definition.
@@ -100,6 +102,7 @@ The full statements are in `course/08-push-chains/scenarios.md`.
- `test_08_text_pushed`: Predict question 5. Text is pushed by the base rule, and `rules()` after the move includes `ROCK IS PUSH`.
- `test_08_level_edge_is_stop`: Predict question 6. A word on the edge can't be pushed off, and Baba can't step off either.
- `test_08_stacked_you_moves_together`: Predict question 7, with a third Baba stacked on the second. Every Baba moves exactly one tile, and the stack stays a stack.
- `test_08_stack_is_age_ordered`: Baba walks onto a tile with a rock and a key placed after it. `stackAt` lists Baba first: it arrived last, but it is the oldest.
The third test:
@@ -184,7 +187,7 @@ Run `cargo test` or `npm test`. The Module 8 tests fail to compile (Rust) or fai
## Build
Make the six tests pass, and keep Modules 1–7 green. Where code goes:
Make the seven tests pass, and keep Modules 1–7 green. Where code goes:
- **Plan, then commit.** A move has two phases. The first reads the board and builds a plan: which objects step one tile, which are destroyed. It changes nothing. The second applies the plan. If the plan fails, there is nothing to undo.
- **A failed branch leaves no trace.** While planning, an attempt can fail halfway: question 4's key finds its unlock, and then the wall blocks it. In the course's reading, whatever that attempt added to the plan (the destruction of key and door) goes too. Decide how your planner forgets.
@@ -280,7 +283,7 @@ pub fn try_move(&mut self, id: ObjectId) -> bool {
return true;
}
if self.stepped.contains(&id) {
return true; // it has stepped in this move already: not again, and not in the way
return true;
}
let level = self.level;
let o = level.board().get(id);
@@ -309,7 +312,7 @@ pub fn try_move(&mut self, id: ObjectId) -> bool {
```ts
tryMove(id: ObjectId): boolean {
if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true;
if (this.stepped.has(id)) return true; // it has stepped in this move already: not again, and not in the way
if (this.stepped.has(id)) return true;
const o = this.level.board.get(id);
const [dx, dy] = delta(this.dir);
const to: Pos = [o.x + dx, o.y + dy];
@@ -326,6 +329,8 @@ tryMove(id: ObjectId): boolean {
Three things make it work. The plan is copied before an attempt and restored if the attempt fails, so a failed branch leaves no trace (Predict question 4). An object already in the plan answers yes at once, so a chain is visited once and recursion ends. And the function never names a property: everything a property does when something moves is a response, and the planner just runs them in order. That order is the wiki's "OPEN SHUT ... PUSH PULL ... STOP", written down in one list in content.
The second early `return true`, on `stepped`, is the once-per-move rule from the decisions above: an object that has already stepped in this move isn't moved again, and it isn't in the way.
baba-is-auto has the same two phases: `Game::CanMove` checks a move recursively, including "// Check boundary", before `Game::ProcessMove` and `Game::ProcessPush` change anything ([`Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp), from line 1756). Its PUSH, STOP, text and OPEN/SHUT are branches inside `CanMove`; compare that with responses, and think about what each costs when a new word arrives.
**Run the coupling audit** again: `node tools/coupling-audit.mjs ~/my-engine` should print `coupling-audit: ok`. The planner names no noun: it moves whatever the rules say is `YOU` and runs whatever responses content gives it.
@@ -12,6 +12,6 @@ The course's own engine as of the end of Module 8. Part I's card engine is uncha
- The rest of `src/engine/`: Part I, unchanged.
- `src/content/movement.rs` (new) and `src/content/mod.rs`: the movement responses, in the order the engine runs them: `OPEN`/`SHUT`, `PUSH`, `STOP`. What a property does when something moves is content, not engine.
- `src/content/words.json`: Module 8 adds `OPEN` and `SHUT`.
- `src/harness.rs`: the adapter the course tests call. Module 8 adds `Baba::move_dir`, `Baba::pos_of`, `Baba::kinds_at`, and re-exports `Dir`.
- `src/harness.rs`: the adapter the course tests call. Module 8 adds `Baba::move_dir`, `Baba::pos_of`, `Baba::kinds_at`, `Baba::stack_at`, and re-exports `Dir`.
Run it with the module's tests: `node tools/verify-references.mjs 08` from the course repository.
@@ -306,6 +306,12 @@ impl Baba {
kinds.sort();
kinds
}
/// The kinds on tile (x, y), as `kinds_at`, but oldest object first.
pub fn stack_at(&self, x: i32, y: i32) -> Vec<String> {
let board = self.level.board();
board.at(x, y).map(|o| o.kind.to_string()).collect()
}
}
fn normalised(rules: &[puzzle::Rule]) -> Vec<String> {
+1 -1
View File
@@ -12,6 +12,6 @@ The course's own engine as of the end of Module 8. Part I's card engine is uncha
- The rest of `src/engine/`: Part I, unchanged.
- `src/content/movement.ts` (new) and `src/content/index.ts`: the movement responses, in the order the engine runs them: `OPEN`/`SHUT`, `PUSH`, `STOP`. What a property does when something moves is content, not engine.
- `src/content/words.json`: Module 8 adds `OPEN` and `SHUT`.
- `src/harness.ts`: the adapter the course tests call. Module 8 adds `move`, `posOf` and `kindsAt` on `Baba`.
- `src/harness.ts`: the adapter the course tests call. Module 8 adds `move`, `posOf`, `kindsAt` and `stackAt` on `Baba`.
Run it with the module's tests: `node tools/verify-references.mjs 08` from the course repository.
@@ -155,6 +155,7 @@ 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
}
/** Distinct rules as normalised strings, sorted. */
@@ -178,5 +179,9 @@ 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)),
};
}
+11
View File
@@ -70,3 +70,14 @@ Words used: the nouns `BABA`, `ROCK`, `WALL`, `KEY`, `DOOR`; the properties `YOU
- Assertions:
- `posOf(ahead)` is (3,2), and `posOf(first)` and `posOf(second)` are both (2,2).
- Every Baba moved exactly one tile. An object steps at most once per move: the Baba ahead isn't pushed a second tile by the ones behind it, and the second Baba of the stack follows the first onto its tile instead of pushing it on or being left behind.
### test_08_stack_is_age_ordered
- Setup: `newBaba` with an empty level 3 wide and 3 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`. Place an object `baba` at (0,2), then an object `rock` and an object `key` both at (1,2). No rule names the rock or the key, so neither blocks.
- Assertions 1: `stackAt(1,2)` is `["rock", "key"]`: the rock was placed first.
- Action: `move("right")`.
- Assertions 2:
- `posOf(baba)` is (1,2).
- `kindsAt(1,2)` is `["baba", "key", "rock"]`, sorted by name.
- `stackAt(1,2)` is `["baba", "rock", "key"]`, oldest first: Baba arrived on the tile last, but it is the oldest object there. Age is when an object was placed, not when it arrived.
- `stackAt(0,2)` is `[]`.
@@ -161,3 +161,22 @@ fn test_08_stacked_you_moves_together() {
assert_eq!(b.pos_of(first), Some((2, 2)));
assert_eq!(b.pos_of(second), Some((2, 2)));
}
#[test]
fn test_08_stack_is_age_ordered() {
let mut b = new_baba(empty_level(3, 3));
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
let baba = b.place("baba", 0, 2);
b.place("rock", 1, 2);
b.place("key", 1, 2);
assert_eq!(b.stack_at(1, 2), vec!["rock", "key"]);
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(baba), Some((1, 2)));
assert_eq!(b.kinds_at(1, 2), vec!["baba", "key", "rock"]);
// Oldest first: Baba arrived last, but it was placed first.
assert_eq!(b.stack_at(1, 2), vec!["baba", "rock", "key"]);
assert_eq!(b.stack_at(0, 2), Vec::<String>::new());
}
@@ -163,3 +163,25 @@ test("test_08_stacked_you_moves_together", () => {
expect(b.posOf(first)).toEqual([2, 2]);
expect(b.posOf(second)).toEqual([2, 2]);
});
test("test_08_stack_is_age_ordered", () => {
const b = newBaba(emptyLevel(3, 3));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
const baba = b.place("baba", 0, 2);
b.place("rock", 1, 2);
b.place("key", 1, 2);
expect(b.stackAt(1, 2)).toEqual(["rock", "key"]);
b.move("right");
expect(b.posOf(baba)).toEqual([1, 2]);
expect(b.kindsAt(1, 2)).toEqual(["baba", "key", "rock"]);
// Oldest first: Baba arrived last, but it was placed first.
expect(b.stackAt(1, 2)).toEqual(["baba", "rock", "key"]);
expect(b.stackAt(0, 2)).toEqual([]);
});