feat: Stress it beat for Modules 6-10, Module 11 (Break your own engine), level solver tool

- lessons 8 and 9 updated for the once-per-move rule, per-object negated
  conversion and the new ordering tests
- tools/solve-spellgrid.mjs: breadth-first search over a SpellGrid level
  with the TypeScript reference; --never-rule and --naive
- README, harness.md and the mentor skill know the new beat and module

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 09:32:22 +01:00
1 parent e907f17937
commit 4cb0f3a61f
13 files changed
+771 -19

No files matched your search

+66 -6
View File
@@ -62,7 +62,7 @@ Write your answers down before you open the discussion, for example in `notes/08
6. The course's answer: the edge of the level blocks like STOP, so the rock doesn't move and neither does Baba. The sources don't say this directly; see "How the real game differs".
7. The sources read for this course don't cover it. If your engine moves the Babas one at a time and the left one goes first, it pushes the right one, and then the right one takes its own step: it moved two tiles. The reference moves the YOU object furthest ahead first, so each moves once. That is a choice; make yours deliberately.
7. The sources read for this course don't cover it. If your engine moves the Babas one at a time and the left one goes first, it pushes the right one, and then the right one takes its own step: it moved two tiles. The reference moves the YOU object furthest ahead first, and it also keeps a rule that doesn't depend on the order: an object steps at most once per move. That is a choice; make yours deliberately. (The order alone is not enough. The Expansion Drop shows a case where it fails.)
</details>
@@ -99,6 +99,7 @@ The full statements are in `course/08-push-chains/scenarios.md`.
- `test_08_open_shut_during_push`: Predict question 3. Key and door are destroyed and Baba is on the key's former tile, the course's inference.
- `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.
The third test:
@@ -183,13 +184,13 @@ Run `cargo test` or `npm test`. The Module 8 tests fail to compile (Rust) or fai
## Build
Make the five tests pass, and keep Modules 1–7 green. Where code goes:
Make the six 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.
- **The edge blocks.** A step that would leave the level fails, like a step into STOP.
- **Rules from before the move.** Every question asked while planning ("is this rock PUSH?") uses the rules in force when the move began. Reparse once, after all movement.
- **Several YOU objects.** Plan and commit them one at a time. Pick an order, and make sure a YOU object that is also PUSH doesn't move twice.
- **Several YOU objects.** Plan and commit them one at a time. Pick an order, and make sure no object steps twice in one move: not a YOU object that is also PUSH, and not anything else a later mover could carry along.
- **One more question.** PUSH, STOP and OPEN/SHUT each say what happens when something moves. Is each of them a branch in your planner, or something the planner runs? The Expansion Drop will bring a new movement property, and you won't be allowed to touch `src/engine/`.
<details><summary>Hint 1 — nudge</summary>
@@ -213,13 +214,17 @@ Write a function that answers one question: "can this object step one tile in th
```text
move(dir):
you = objects with YOU, furthest ahead in dir first
for each id in you that still exists:
stepped = {} // objects that have stepped in this move
for each id in you that still exists and is not in stepped:
plan = { moving: {}, destroyed: {} }
if try_move(plan, id, dir): commit(plan, dir)
if try_move(plan, id, dir):
commit(plan, dir)
stepped += plan.moving - plan.destroyed
reparse()
try_move(plan, id, dir): // reads the board, never changes it
if id in plan.moving or id in plan.destroyed: return true
if id in stepped: return true // it has had its step; it is not moved again
to = position(id) + dir
if to is outside the level: return false
saved = copy of plan
@@ -250,6 +255,7 @@ Decisions in the reference that the tests don't force:
- **The responses are content.** The engine owns the planner, the edge, the order of the YOU objects and the commit. What `OPEN`/`SHUT`, `PUSH` and `STOP` do is three functions in `src/content/`, which the engine runs for every step in the order content lists them. `YOU` stays in the engine: it picks who moves when the player presses a key.
- **Several YOU objects move furthest-ahead first**, oldest first on a tie, each planned and committed before the next is planned. All of them use the rules from before the first.
- **An object steps at most once per move.** The level keeps the set of objects that have stepped in a committed plan. A `YOU` object in the set doesn't start a move of its own, and the planner answers "yes, and nothing to add" when a later mover asks one of them to step: it isn't moved again and it doesn't hold the mover back. So two stacked `YOU AND PUSH` objects end the move stacked, one tile on. The sources read don't cover any of this; it is the course's rule.
- **One partner per OPEN/SHUT step: the oldest** SHUT (or OPEN) object on the tile. The [OPEN](https://babaiswiki.fandom.com/wiki/OPEN) page: "Only one SHUT object is destroyed per OPEN object (and vice versa), based on the first object in update order." The reference's update order is object age.
- **A destroyed object has no properties.** `hasProperty` on it is `false`, and `posOf` is `null`.
@@ -273,6 +279,9 @@ pub fn try_move(&mut self, id: ObjectId) -> bool {
if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) {
return true;
}
if self.stepped.contains(&id) {
return true; // it has stepped in this move already: not again, and not in the way
}
let level = self.level;
let o = level.board().get(id);
let (dx, dy) = self.dir.delta();
@@ -300,6 +309,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
const o = this.level.board.get(id);
const [dx, dy] = delta(this.dir);
const to: Pos = [o.x + dx, o.y + dy];
@@ -335,7 +345,7 @@ That is new behaviour: PUSH looks at the tile ahead of the mover, PULL at the ti
node tools/expansion-check.mjs ~/my-engine HEAD
```
The test, `test_08x_pull_follows`, puts two PULL rocks behind Baba. Baba moves away and both rocks follow, one tile each. Then Baba turns back: the rock is solid, so nothing moves. The full scenario is in `course/08-push-chains/expansion/README.md`.
The first test, `test_08x_pull_follows`, puts two PULL rocks behind Baba. Baba moves away and both rocks follow, one tile each. Then Baba turns back: the rock is solid, so nothing moves. The second, `test_08x_pulled_you_moves_once`, puts a key that is `YOU` and `PULL` behind Baba: the key is pulled one tile and must not then take a step of its own. The full scenarios are in `course/08-push-chains/expansion/README.md`.
<details><summary>Discussion: what this drop measures</summary>
@@ -377,6 +387,7 @@ Shapes that fail this drop:
- **Properties as branches in the planner.** If "can this step?" says `if PUSH ... else if STOP ...`, PULL is a third branch, under `src/engine/`. Make the reactions something the planner runs, as a normal commit, then redo the drop.
- **A planner that only lets a reaction see the tile ahead.** If your responses are handed "the objects on the target tile" and nothing else, PULL can't see the object behind the mover. Give a response the step (where from, where to, which way) and a way to ask about any tile.
- **Moving while you walk.** If your engine moves objects as it finds them, a pull that fails halfway leaves objects moved. With a plan, a pull that can't happen is simply never in the plan.
- **"Furthest ahead first" as the only guard against double moves.** That order stops a pusher from moving something that hasn't had its own turn yet, because whatever you push is ahead of you. A pulled object is *behind* its mover: the leader goes first, drags the key one tile, and then the key's own turn comes. `test_08x_pulled_you_moves_once` fails with the key two tiles on. The first version of the course's reference had this bug, and no test saw it, because the only guard was an ordering that happened to work for PUSH. The fix is a rule that doesn't mention any property (an object steps once per move), and it is an engine change: a normal commit, then redo the drop.
The reference lets a PULL object that can't follow stay behind without holding the mover back. The PULL page doesn't say, and the test doesn't check it.
@@ -388,9 +399,58 @@ If the check fails, fix your engine (a normal commit, outside the drop), then re
</details>
## Stress it
Optional. Each question pushes the design past what this module's tests check. Predict first, then open the discussion.
1. `try_move` copies the whole plan before every attempt so that it can restore it. For a chain of *n* rocks, how much copying is that? Replace the copy with something cheaper that restores exactly as well.
<details><summary>Discussion</summary>
Each of the *n* nested attempts copies a plan that already holds up to *n* ids, so a straight push costs on the order of *n*² set insertions. On a level this size nobody will notice. The shape is still worth knowing, because the same choice appears in every system that has to undo.
The cheaper version is a **journal**. Keep the plan as an append-only list of entries ("moving: 7", "destroyed: 12"). A savepoint is just the list's current length; restoring is truncating the list back to that length. Nothing is copied on the way in, and only a failed branch pays, in proportion to what it added. Databases call the two designs shadow copies and undo logs.
Before you switch, write the test that would catch a wrong restore: Predict question 4's board (the key finds its unlock, then a wall blocks it), with an assertion that the door still exists. The lesson says no Module 8 test sets this board up. A change to the restore logic is the moment that gap matters.
</details>
2. "An object already in the plan answers yes at once ... That also stops any cycle." On this module's boards, can a push chain ever come back to an object it has already visited? If not, what is that line actually for? Describe a level geometry where it would be load-bearing.
<details><summary>Discussion</summary>
It can't. Every recursive call looks one tile further in the same direction, on a board with edges, so a chain is a straight line that ends at the edge. PULL looks one tile *back*, at an object the mover is leaving, and that object's own step looks further back still. No path returns.
So on these boards the check is not cycle protection. Its job is deduplication: with PULL in content, the same object can be reached twice in one plan (a rock behind the mover is pulled, and is also on the tile some other object in the plan is leaving), and it must be planned once.
Make the level wrap around (step off the right edge, arrive at the left) and the line becomes load-bearing: a full row of PUSH rocks pushes itself, and without the check `try_move` recurses forever. With it, the row rotates one tile, which is a sensible answer. A guard whose purpose you can't state is a guard you will delete one day; write the purpose next to it.
</details>
3. Your planner decides one move in time proportional to the chain. How hard is it to decide whether a whole level can be *solved*? Guess before you look.
<details><summary>Discussion</summary>
Very. Sokoban, which is this module with one property and no text, is the standard example: "deciding whether any given Sokoban puzzle is solvable is a problem known to be NP-hard and PSPACE-complete" ([Sokoban, Wikipedia](https://en.wikipedia.org/wiki/Sokoban), citing Dor and Zwick 1999 and Culberson 1997). Pushes are easy to do and hard to take back, so positions that look alike differ in what is still reachable, and a solver can't avoid exploring them.
That is not a reason to give up on checking levels. Complexity results are about how the cost grows with the size of the puzzle, and a designer's levels are small. A brute-force search over a 9 × 7 level finishes in seconds, and it will tell you two things no amount of play-testing can: that a level has *no* solution other than the one you intended, or that it has one you didn't. [Module 11](../11-break-your-engine/lesson.md) builds that search.
</details>
4. Press right, then left. Is the board back where it started? What does your answer mean for an Undo button?
<details><summary>Discussion</summary>
Usually not. Baba pushes a rock right; stepping left walks away from it. Moves are not their own inverses, because pushing loses information: after the push you can't tell from the board whether the rock was pushed there or always stood there. A destroyed key and door are gone entirely.
So undo can't be "apply the opposite move". It has to be one of two things: remember enough to put the old state back (a snapshot of the board, or the plan that was committed plus what it destroyed), or replay the level from the start without the last input. Both work here, and both are much easier than they would be in Part I, for a reason this part of the course set up: the rules are *derived* from the board. Restore the objects, reparse, and every rule is right again, with no rule history to keep. [Module 9](../09-turn-pipeline/lesson.md)'s Stress it asks what, exactly, "the objects" has to include.
</details>
## Further reading
- The Baba Is You Wiki's [PUSH](https://babaiswiki.fandom.com/wiki/PUSH), [STOP](https://babaiswiki.fandom.com/wiki/STOP), [OPEN](https://babaiswiki.fandom.com/wiki/OPEN) and [SHUT](https://babaiswiki.fandom.com/wiki/SHUT) pages: every rule in this module and the ones it leaves out (SAFE, HAS, float levels). Then [PULL](https://babaiswiki.fandom.com/wiki/PULL) for the drop.
- The [Esolangs wiki's Baba Is You page](https://esolangs.org/wiki/Baba_Is_You): the game read as a cellular automaton, with a table of words. Its PUSH, STOP and OPEN/SHUT entries are the shortest statement of this module.
- The Baba Is You Wiki's [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations): the movement checks in order, and where movement sits in the turn you build in Module 9.
- [Sokoban, Wikipedia](https://en.wikipedia.org/wiki/Sokoban), the Research section: what is known about how hard push puzzles are, with references. Useful background before you write a solver.
- baba-is-auto, `Game::CanMove`, `Game::ProcessMove` and `Game::ProcessPush` in [`Sources/baba-is-auto/Games/Game.cpp`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Sources/baba-is-auto/Games/Game.cpp) (from line 1756): validate, then commit, in C++. It is an example, **not an authority** on the game: it hard-codes text as pushable and its float handling is partial ([`rule-language.md`](https://github.com/utilForever/baba-is-auto/blob/2305a3d/Documents/rule-language.md)).