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.