diff --git a/course/08-push-chains/lesson.md b/course/08-push-chains/lesson.md index 29dbe72..12c489e 100644 --- a/course/08-push-chains/lesson.md +++ b/course/08-push-chains/lesson.md @@ -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` | +| `stackAt(x, y): string[]` | `stack_at(&self, x: i32, y: i32) -> Vec` | - `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. diff --git a/course/08-push-chains/reference/rust/README.md b/course/08-push-chains/reference/rust/README.md index ec35831..106cdf7 100644 --- a/course/08-push-chains/reference/rust/README.md +++ b/course/08-push-chains/reference/rust/README.md @@ -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. diff --git a/course/08-push-chains/reference/rust/src/harness.rs b/course/08-push-chains/reference/rust/src/harness.rs index 51957e6..f3efd4f 100644 --- a/course/08-push-chains/reference/rust/src/harness.rs +++ b/course/08-push-chains/reference/rust/src/harness.rs @@ -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 { + let board = self.level.board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } } fn normalised(rules: &[puzzle::Rule]) -> Vec { diff --git a/course/08-push-chains/reference/ts/README.md b/course/08-push-chains/reference/ts/README.md index c77694e..1f806ef 100644 --- a/course/08-push-chains/reference/ts/README.md +++ b/course/08-push-chains/reference/ts/README.md @@ -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. diff --git a/course/08-push-chains/reference/ts/src/harness.ts b/course/08-push-chains/reference/ts/src/harness.ts index 866d225..a0a3371 100644 --- a/course/08-push-chains/reference/ts/src/harness.ts +++ b/course/08-push-chains/reference/ts/src/harness.ts @@ -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)), }; } diff --git a/course/08-push-chains/scenarios.md b/course/08-push-chains/scenarios.md index 8b8e2e1..1af3f90 100644 --- a/course/08-push-chains/scenarios.md +++ b/course/08-push-chains/scenarios.md @@ -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 `[]`. diff --git a/course/08-push-chains/tests/rust/test_08.rs b/course/08-push-chains/tests/rust/test_08.rs index 24edfb4..14fc319 100644 --- a/course/08-push-chains/tests/rust/test_08.rs +++ b/course/08-push-chains/tests/rust/test_08.rs @@ -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::::new()); +} diff --git a/course/08-push-chains/tests/ts/test_08.test.ts b/course/08-push-chains/tests/ts/test_08.test.ts index cb1cdc2..ab081e9 100644 --- a/course/08-push-chains/tests/ts/test_08.test.ts +++ b/course/08-push-chains/tests/ts/test_08.test.ts @@ -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([]); +}); diff --git a/course/09-turn-pipeline/lesson.md b/course/09-turn-pipeline/lesson.md index 85da8f6..e721eae 100644 --- a/course/09-turn-pipeline/lesson.md +++ b/course/09-turn-pipeline/lesson.md @@ -101,17 +101,19 @@ node tools/start-module.mjs 09 ~/my-engine ### The harness -Three new functions on `Baba`: +Four new functions on `Baba`: | TypeScript | Rust | |---|---| | `step(dir): void` | `step(&mut self, dir: Dir)` | | `won(): boolean` | `won(&self) -> bool` | | `lost(): boolean` | `lost(&self) -> bool` | +| `undo(): boolean` | `undo(&mut self) -> bool` | - `step(dir)` plays one whole turn, in the course's order: **Movement → Parse → Conversion → Parse → Hazards → Win**, then one last parse, so that `rules()` matches the board. Movement is Module 8's, without its reparse. Module 8's `move(dir)` stays as it was. - `won()` is true once a turn's win check has found a `YOU` object that is `WIN`, or that shares its tile with a `WIN` object on the same float level. It stays true. - `lost()` is true when no object is `YOU`. +- `undo()` puts the level back as it was before the last `step` or `move`, and returns true. Called again, it goes one further back. With nothing left to undo it returns false and changes nothing. `place` forgets the history: you can't undo past it. What the phases do: @@ -142,8 +144,10 @@ The full statements are in `course/09-turn-pipeline/scenarios.md`. - `test_09_last_parse_matches_board`: a hazard destroys a word. `rules()` after the turn no longer has that word's sentence. - `test_09_hazard_order_melt_before_defeat`: Predict question 11. - `test_09_hazard_order_sink_before_melt`: a key that is `HOT` and `MELT` lies on water that is `SINK`. Both are destroyed, because SINK runs first. +- `test_09_undo_restores_everything`: a turn that moves, converts, destroys and wins, then `undo()`. Every tile's `kindsAt` and `stackAt`, every `posOf`, the rules and `won()` are as before the turn. The same turn again gives the same result, ids included. +- `test_09_undo_walks_back_and_stops`: two steps, three undos. The third returns false. -The last five look like a lot of tests for one list. Each exists because the turn makes a separate ordering decision there, and an engine with that one decision changed passed every earlier test in the course. [Module 13](../13-break-your-engine/lesson.md) shows the experiment. +The five from `test_09_conversion_reparses_before_hazards` to `test_09_hazard_order_sink_before_melt` look like a lot of tests for one list. Each exists because the turn makes a separate ordering decision there, and an engine with that one decision changed passed every earlier test in the course. [Module 13](../13-break-your-engine/lesson.md) shows the experiment. The first test: @@ -211,7 +215,7 @@ Run `cargo test` or `npm test`. The Module 9 tests fail to compile (Rust) or fai ## Build -Make the fourteen tests pass, and keep Modules 1–8 green. Where code goes: +Make the sixteen tests pass, and keep Modules 1–8 green. Where code goes: - **The turn is a list.** Write the order down once, as a list of phases, and have `step` walk it. Movement doesn't call parsing, and parsing doesn't call conversion. Each phase is called by the turn. - **Parse between phases.** Module 8's `move` reparsed at the end. In the turn, parsing is a phase of its own. It runs after movement and again after conversion, and each phase uses the rules from the parse before it. Hazards and the win check share one set of rules: a hazard that destroys text doesn't change the rules until the turn's last parse. @@ -219,6 +223,7 @@ Make the fourteen tests pass, and keep Modules 1–8 green. Where code goes: - **Hazards: destroy together, then the next.** "All objects destroyed in the same step are destroyed simultaneously." Collect a hazard's victims first, then remove them. An object that is HOT and MELT destroys itself, and a second MELT object on its tile goes with it. Remove the first as soon as you find it, and the second no longer has HOT company, so it survives. - **Float levels.** Every hazard and the win check ask the same question: which other objects share this tile, on this float level? Answer it in one place. - **Win last, lost any time.** The win check runs after the hazards, so a destroyed YOU can't win. `lost()` just asks whether anything is YOU now. +- **Undo: save before, restore after.** Before each `step` and `move`, save what the level will need to be put back; `undo` restores the last save. What exactly to save is Stress it question 3. Try to answer it before you read the undo test. - **One more question.** In Module 8, each movement property was something the planner ran. Is each hazard a branch in your hazards phase, or something the phase runs? The Expansion Drop brings a new hazard, and you won't be allowed to touch `src/engine/`.
Hint 1 — nudge @@ -286,6 +291,7 @@ Decisions in the reference that the tests don't force: - **`X IS NOT X` deletes in the conversion step**, together with the conversions. The deleted object's other targets still appear, as new objects. No source gives the deletion its own place in the turn; see "How the real game differs". - **`won()` stays true.** In the game, winning ends the level. In the course, `step` keeps playing if you call it again, and `won()` stays true. - **The last parse.** The course's turn ends with a parse, as the game's does, so `rules()` after `step` matches the board. No phase reads it until the next turn. +- **Undo is a stack of snapshots in the level.** Before each move or turn, `Level` saves a copy of the board (its objects, oldest first, with their ids, and the id counter) and `won`. `undo` pops the last one, puts it back and reparses. The rules are not saved. Replaying the inputs would work too (Stress it question 3); a snapshot makes `undo` cheap, with no replay. SpellGrid, in Module 10, has no `undo`: Part I's health, hands and log are state that the board doesn't hold. > **How the real game differs** > - **The order.** The course's turn is a labelled simplification. Side by side, the course on the left and the game, from [Order of Operations](https://babaiswiki.fandom.com/wiki/Order_of_Operations), on the right: @@ -449,7 +455,7 @@ Optional. Each question pushes the design past what this module's tests check. P
Discussion -With this module's fourteen tests, each of those changes fails at least one test, and the name of the failing test tells you which decision you broke. On the reference: +With this module's sixteen tests, each of those changes fails at least one test, and the name of the failing test tells you which decision you broke. On the reference: | Change to the turn | Fails | |---|---| @@ -481,7 +487,7 @@ An order that can't be observed isn't a rule of the game yet, only a habit of th
-3. Add an Undo to your harness: `undo()` puts the level back as it was before the last `step`. Build it by saving something before each step and restoring it. What is the smallest thing you can save? Then test it: play twenty random steps, undo them all, play the same twenty again, and compare every `kindsAt`, `rules()` and `posOf` with the first run. +3. Your `undo()` saves something before each step and restores it. What is the smallest thing you can save? Write down your list, then compare it with what the reference's `Level` saves (`save` and `undo` in `engine/puzzle/level`). Then test it beyond the two tests: play twenty random steps, undo them all, play the same twenty again, and compare every `kindsAt`, `stackAt`, `rules()` and `posOf` with the first run.
Discussion @@ -490,10 +496,10 @@ Not the rules: they are compiled from the board, so restoring the board and repa "The board" is more than it looks, though. Try the cheapest snapshot, a list of (kind, x, y) read off with `kindsAt`, and rebuild the level from it. The replay will sooner or later differ from the first run, and the differences show what else is state: - **Ids.** A rebuilt level numbers its objects afresh. Anything outside the engine that holds an id (your test, a UI) now points at the wrong object. Module 1's handles are only stable if undo preserves them. -- **Age.** The reference breaks ties by object age: the oldest SHUT partner is the one destroyed, and the older of two YOU objects on a line moves first. `kindsAt` returns kinds sorted by name, so a snapshot made from it forgets which object on a tile was older, and two objects of different kinds can swap seniority. +- **Age.** The reference breaks ties by object age: the oldest SHUT partner is the one destroyed, and the older of two YOU objects on a line moves first. `kindsAt` returns kinds sorted by name, so a snapshot made from it forgets which object on a tile was older, and two objects of different kinds can swap seniority. `stackAt` shows the order a snapshot has to keep. - **`won`.** It stays true once set, and it is not on the board. -So the smallest correct snapshot is every object's id, kind and position, in age order, plus the id counter and `won`. The exercise is finding that list; an undo that is right nineteen times in twenty comes from guessing it. +So the smallest correct snapshot is every object's id, kind and position, in age order, plus the id counter and `won`. That is exactly what the reference saves: a copy of the board (its objects in age order, with ids, and its id counter) and `won`, and nothing else. `test_09_undo_restores_everything` checks each item: ids through `posOf`, age through `stackAt`, `won()` directly, and the id counter by playing the turn again and getting the same id for the new flag. The exercise is finding that list; an undo that is right nineteen times in twenty comes from guessing it. The other design is to save nothing but the inputs and replay all but the last. It can't forget a piece of state, because it never copies any. It costs a replay per undo, and it requires the engine to be deterministic, which yours is. Robert Nystrom's [*Command*](https://gameprogrammingpatterns.com/command.html) chapter covers both designs; Martin Fowler's [*Event Sourcing*](https://martinfowler.com/eaaDev/EventSourcing.html) is the second one taken as an architecture: "we can also use the event log to reconstruct past states". diff --git a/course/09-turn-pipeline/reference/rust/README.md b/course/09-turn-pipeline/reference/rust/README.md index d7367f0..2030896 100644 --- a/course/09-turn-pipeline/reference/rust/README.md +++ b/course/09-turn-pipeline/reference/rust/README.md @@ -5,7 +5,7 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - `cards/` (at the crate root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it. - `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT`, the property `PUSH` of the base rule `TEXT IS PUSH`, the property `YOU`, which picks the objects that move, and from Module 9 the properties `WIN` (the win check) and `FLOAT` (which objects share a tile). - `turn.rs` (new): the turn. `Phase`, `TURN` (the phases in order) and `Hazard` (content's rule for what destroys what). - - `level.rs`: a level in play. Module 9 adds `Level::step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes, and `... IS NOT Y` keeps every object it names from becoming a `Y`, as `has_property` settles negation per object); the hazards phase, which runs content's hazards in order; the win check; `Level::sharing` (the other objects on a tile at the same float level); `won` and `lost`. `Level::new` takes the hazards. `move_you` is now movement, then a reparse. + - `level.rs`: a level in play. Module 9 adds `Level::step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes, and `... IS NOT Y` keeps every object it names from becoming a `Y`, as `has_property` settles negation per object); the hazards phase, which runs content's hazards in order; the win check; `Level::sharing` (the other objects on a tile at the same float level); `won` and `lost`. `Level::new` takes the hazards. `move_you` is now movement, then a reparse. `Level::undo` puts back the snapshot saved before the last `move_you` or `step` (a clone of the board, and `won`) and reparses; `place` clears the history. - `grid.rs`: Module 9 adds `Board::set_kind`. - `word.rs`: Module 9 adds `Lexicon::thing_named` (what a conversion into a noun makes). - `movement.rs`, `parse.rs`, `resolve.rs`: unchanged. @@ -14,6 +14,6 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - `src/content/hazards.rs` (new) and `src/content/mod.rs`: the hazards, in the order the engine runs them: `SINK`, `HOT`/`MELT`, `DEFEAT`. What destroys what at the end of a turn is content, not engine. - `src/content/movement.rs`: unchanged. - `src/content/words.json`: Module 9 adds `HOT`, `MELT`, `SINK`, `DEFEAT` and `FLOAT`, and `ALL`, the Module 7 Expansion Drop, which the Module 9 tests need. -- `src/harness.rs`: the adapter the course tests call. Module 9 adds `Baba::step`, `Baba::won` and `Baba::lost`. +- `src/harness.rs`: the adapter the course tests call. Module 9 adds `Baba::step`, `Baba::won`, `Baba::lost` and `Baba::undo`. Run it with the module's tests: `node tools/verify-references.mjs 09` from the course repository. diff --git a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/level.rs b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/level.rs index df6a593..cac001c 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/level.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/level.rs @@ -7,6 +7,10 @@ //! //! 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. use std::collections::BTreeSet; @@ -26,6 +30,16 @@ pub struct Level { rules: Vec, active: Vec, won: bool, + /// What `undo` puts back: one entry per move or turn, the latest last (Module 9). + history: Vec, +} + +/// The level before a move or a turn: the whole board (its objects, oldest first, +/// and its id counter) and `won`. +#[derive(Debug, Clone)] +struct Snapshot { + board: Board, + won: bool, } /// The property that makes an object move when the player moves. @@ -53,6 +67,7 @@ impl Level { rules: vec![], active: vec![], won: false, + history: vec![], }; for o in &def.objects { level.board.place(Kind::parse(&o.kind), o.x, o.y); @@ -63,6 +78,7 @@ impl Level { pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { let id = self.board.place(Kind::parse(kind), x, y); + self.history.clear(); self.reparse(); id } @@ -127,12 +143,14 @@ impl Level { /// Move every `YOU` object one tile in `dir`, then reparse (Module 8). Movement /// only: no conversion, hazard or win. pub fn move_you(&mut self, dir: Dir) { + self.save(); self.movement(dir); self.reparse(); } /// Play one turn: the phases of `TURN`, in order. pub fn step(&mut self, dir: Dir) { + self.save(); for phase in TURN { match phase { Phase::Movement => self.movement(dir), @@ -144,6 +162,26 @@ impl Level { } } + /// Put the level back as it was before the last move or turn (Module 9), and + /// reparse. False, and no change, if there is none; `place` forgets them all. + pub fn undo(&mut self) -> bool { + let Some(last) = self.history.pop() else { + return false; + }; + self.board = last.board; + self.won = last.won; + self.reparse(); + true + } + + fn save(&mut self) { + let snapshot = Snapshot { + board: self.board.clone(), + won: self.won, + }; + self.history.push(snapshot); + } + /// 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 diff --git a/course/09-turn-pipeline/reference/rust/src/harness.rs b/course/09-turn-pipeline/reference/rust/src/harness.rs index c0503ce..89f174f 100644 --- a/course/09-turn-pipeline/reference/rust/src/harness.rs +++ b/course/09-turn-pipeline/reference/rust/src/harness.rs @@ -312,6 +312,12 @@ impl Baba { kinds } + /// The kinds on tile (x, y), as `kinds_at`, but oldest object first. + pub fn stack_at(&self, x: i32, y: i32) -> Vec { + let board = self.level.board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } + // ---- Module 9 ---- /// Play one whole turn: movement, parse, conversion, parse, hazards, win @@ -329,6 +335,12 @@ impl Baba { pub fn lost(&self) -> bool { self.level.lost() } + + /// Put the level back as it was before the last `step` or `move_dir`. False, and + /// no change, if there is none; repeated calls walk further back. + pub fn undo(&mut self) -> bool { + self.level.undo() + } } fn normalised(rules: &[puzzle::Rule]) -> Vec { diff --git a/course/09-turn-pipeline/reference/ts/README.md b/course/09-turn-pipeline/reference/ts/README.md index bc21500..8c58fc4 100644 --- a/course/09-turn-pipeline/reference/ts/README.md +++ b/course/09-turn-pipeline/reference/ts/README.md @@ -5,8 +5,8 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - `cards/` (at the package root; not in this directory): Part I's card data, a copy of `course/cards/` staged by `tools/verify-references.mjs`. Part II doesn't read it. - `src/engine/puzzle/`: the Part II engine. It names no noun: the engine's own words are the operators `IS`, `AND`, `NOT`, the meta-noun `TEXT`, the property `PUSH` of the base rule `TEXT IS PUSH`, the property `YOU`, which picks the objects that move, and from Module 9 the properties `WIN` (the win check) and `FLOAT` (which objects share a tile). - `turn.ts` (new): the turn. `Phase`, `TURN` (the phases in order) and `Hazard` (content's rule for what destroys what). - - `level.ts`: a level in play. Module 9 adds `Level.step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes, and `... IS NOT Y` keeps every object it names from becoming a `Y`, as `hasProperty` settles negation per object); the hazards phase, which runs content's hazards in order; the win check; `Level.sharing` (the other objects on a tile at the same float level); `won` and `lost`. The `Level` constructor takes the hazards. `moveYou` is now movement, then a reparse. - - `grid.ts`: Module 9 adds `Board.setKind`. + - `level.ts`: a level in play. Module 9 adds `Level.step`, which runs the phases of `TURN`; the conversion phase (`X IS Y` in place, `X IS X` locks, `X IS NOT X` deletes, and `... IS NOT Y` keeps every object it names from becoming a `Y`, as `hasProperty` settles negation per object); the hazards phase, which runs content's hazards in order; the win check; `Level.sharing` (the other objects on a tile at the same float level); `won` and `lost`. The `Level` constructor takes the hazards. `moveYou` is now movement, then a reparse. `Level.undo` puts back the snapshot saved before the last `moveYou` or `step` (the board and `won`) and reparses; `place` clears the history. + - `grid.ts`: Module 9 adds `Board.setKind`, and `Board.snapshot` and `Board.restore` (every object, oldest first, and the id counter) for undo. - `word.ts`: Module 9 adds `Lexicon.thingNamed` (what a conversion into a noun makes). - `movement.ts`, `parse.ts`, `resolve.ts`: unchanged. - `index.ts`: the module's exports. @@ -14,6 +14,6 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - `src/content/hazards.ts` (new) and `src/content/index.ts`: the hazards, in the order the engine runs them: `SINK`, `HOT`/`MELT`, `DEFEAT`. What destroys what at the end of a turn is content, not engine. - `src/content/movement.ts`: unchanged. - `src/content/words.json`: Module 9 adds `HOT`, `MELT`, `SINK`, `DEFEAT` and `FLOAT`, and `ALL`, the Module 7 Expansion Drop, which the Module 9 tests need. -- `src/harness.ts`: the adapter the course tests call. Module 9 adds `step`, `won` and `lost` on `Baba`. +- `src/harness.ts`: the adapter the course tests call. Module 9 adds `step`, `won`, `lost` and `undo` on `Baba`. Run it with the module's tests: `node tools/verify-references.mjs 09` from the course repository. diff --git a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/grid.ts b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/grid.ts index 90bb653..62c21c5 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/grid.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/grid.ts @@ -43,6 +43,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(); private nextId = 0; @@ -105,4 +111,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; + } } diff --git a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/level.ts b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/level.ts index 7992331..b981780 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/level.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/level.ts @@ -7,8 +7,12 @@ // // 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 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"; @@ -31,6 +35,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 @@ -50,6 +56,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; } @@ -110,12 +117,14 @@ export class Level { * 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": @@ -137,6 +146,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, diff --git a/course/09-turn-pipeline/reference/ts/src/harness.ts b/course/09-turn-pipeline/reference/ts/src/harness.ts index fd54ea3..52277e5 100644 --- a/course/09-turn-pipeline/reference/ts/src/harness.ts +++ b/course/09-turn-pipeline/reference/ts/src/harness.ts @@ -155,10 +155,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. */ @@ -182,8 +184,13 @@ 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(), }; } diff --git a/course/09-turn-pipeline/scenarios.md b/course/09-turn-pipeline/scenarios.md index b163321..d50e0e9 100644 --- a/course/09-turn-pipeline/scenarios.md +++ b/course/09-turn-pipeline/scenarios.md @@ -138,3 +138,26 @@ This test needs the Module 7 Expansion Drop: the word `ALL` in the lexicon. - Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `WATER (0,1) IS (1,1) SINK (2,1)` and `KEY (0,2) IS (1,2) HOT (2,2) AND (3,2) MELT (4,2)`. Place an object `baba` at (0,4), and an object `water` and an object `key` both at (4,4). - Action: `step("right")`. - Assertions: `posOf(key)` and `posOf(water)` are both `null`. SINK runs first, while the key is still on the water's tile, and destroys both. In the other order the key, being HOT and MELT, would destroy itself first and leave the water alone on its tile, where SINK does nothing. The outcome is inferred from the order of the wiki's `block()`; no source shows this board. + +### test_09_undo_restores_everything + +- Setup: `newBaba` with an empty level 5 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `KEY (0,1) IS (1,1) ROCK (2,1) AND (3,1) FLAG (4,1)`, `ROCK (0,2) IS (1,2) MELT (2,2)`, `LAVA (0,3) IS (1,3) HOT (2,3)` and `FLAG (0,4) IS (1,4)`. Place a text `text:WIN` (the win text), an object `key` and an object `lava`, all at (3,4), and an object `baba` at (4,4). Baba is object 20. +- Before: record the view: `kindsAt` and `stackAt` of every tile, `posOf` of every id from 1 to 20, `rules()`, `activeRules()`, `won()` and `lost()`. +- Action 1: `step("left")`. +- Assertions 1, the turn did each kind of thing: + - `posOf(baba)` is (3,4) and `posOf(winText)` is (2,4): Baba pushed `WIN` into `FLAG IS WIN`. + - `posOf(key)` is `null`, and `stackAt(3,4)` is `["lava", "baba", "flag"]`, with `posOf(21)` at (3,4). Conversion made the key a rock (same id) and a new flag (id 21); the rock was `MELT` on `HOT` lava and the hazards destroyed it. + - `rules()` contains `"FLAG IS WIN"` and `won()` is true: Baba shares its tile with the new flag. + - Record the view again, with ids 1 to 21. +- Action 2: `undo()`. +- Assertions 2: it returns true, and the view (ids 1 to 20) equals the one from before the step: every tile's kinds and stack, every position, both rule lists, `won()` false. +- Action 3: `step("left")` again. +- Assertions 3: the view (ids 1 to 21) equals the one after the first step. The new flag is 21 again: undo put back the id counter, not just the objects. + +### test_09_undo_walks_back_and_stops + +- Setup: `newBaba` with an empty level 4 wide and 3 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and an object `baba` at (0,2). +- Action 1: `step("right")` twice. +- Assertions 1: `posOf(baba)` is (2,2). +- Action 2: `undo()`, three times. +- Assertions 2: the first returns true and `posOf(baba)` is (1,2); the second returns true and `posOf(baba)` is (0,2); the third returns false and changes nothing: there is no step left to undo. diff --git a/course/09-turn-pipeline/tests/rust/test_09.rs b/course/09-turn-pipeline/tests/rust/test_09.rs index 12bcec7..2f027ba 100644 --- a/course/09-turn-pipeline/tests/rust/test_09.rs +++ b/course/09-turn-pipeline/tests/rust/test_09.rs @@ -318,3 +318,96 @@ fn test_09_hazard_order_sink_before_melt() { assert_eq!(b.pos_of(key), None); assert_eq!(b.pos_of(water), None); } + +/// Everything the harness shows: every tile's kinds and stack, where each id up to +/// `last_id` is, the rules, the outcome. +#[derive(Debug, PartialEq)] +struct View { + tiles: Vec<(Vec, Vec)>, + positions: Vec>, + rules: Vec, + active: Vec, + won: bool, + lost: bool, +} + +fn view(b: &Baba, width: i32, height: i32, last_id: Id) -> View { + let mut tiles = vec![]; + for y in 0..height { + for x in 0..width { + tiles.push((b.kinds_at(x, y), b.stack_at(x, y))); + } + } + View { + tiles, + positions: (1..=last_id).map(|id| b.pos_of(id)).collect(), + rules: b.rules(), + active: b.active_rules(), + won: b.won(), + lost: b.lost(), + } +} + +#[test] +fn test_09_undo_restores_everything() { + let mut b = new_baba(empty_level(5, 5)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + write( + &mut b, + &[ + ("KEY", 0, 1), + ("IS", 1, 1), + ("ROCK", 2, 1), + ("AND", 3, 1), + ("FLAG", 4, 1), + ], + ); + write(&mut b, &[("ROCK", 0, 2), ("IS", 1, 2), ("MELT", 2, 2)]); + write(&mut b, &[("LAVA", 0, 3), ("IS", 1, 3), ("HOT", 2, 3)]); + write(&mut b, &[("FLAG", 0, 4), ("IS", 1, 4)]); + let win_text = b.place("text:WIN", 3, 4); + let key = b.place("key", 3, 4); + b.place("lava", 3, 4); + let baba = b.place("baba", 4, 4); + assert_eq!(baba, 20); + let before = view(&b, 5, 5, 20); + + b.step(Dir::Left); + + // The turn moved Baba and the word WIN, converted the key, melted it and won. + assert_eq!(b.pos_of(baba), Some((3, 4))); + assert_eq!(b.pos_of(win_text), Some((2, 4))); + assert_eq!(b.pos_of(key), None); + assert_eq!(b.stack_at(3, 4), vec!["lava", "baba", "flag"]); + assert_eq!(b.pos_of(21), Some((3, 4))); + assert!(b.rules().contains(&"FLAG IS WIN".to_string())); + assert!(b.won()); + let after = view(&b, 5, 5, 21); + + assert!(b.undo()); + + assert_eq!(view(&b, 5, 5, 20), before); + + b.step(Dir::Left); + + // The same step gives the same result, ids included: the new flag is 21 again. + assert_eq!(view(&b, 5, 5, 21), after); +} + +#[test] +fn test_09_undo_walks_back_and_stops() { + let mut b = new_baba(empty_level(4, 3)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + let baba = b.place("baba", 0, 2); + + b.step(Dir::Right); + b.step(Dir::Right); + + assert_eq!(b.pos_of(baba), Some((2, 2))); + assert!(b.undo()); + assert_eq!(b.pos_of(baba), Some((1, 2))); + assert!(b.undo()); + assert_eq!(b.pos_of(baba), Some((0, 2))); + assert!(!b.undo()); + assert_eq!(b.pos_of(baba), Some((0, 2))); +} diff --git a/course/09-turn-pipeline/tests/ts/test_09.test.ts b/course/09-turn-pipeline/tests/ts/test_09.test.ts index c88612f..f698b01 100644 --- a/course/09-turn-pipeline/tests/ts/test_09.test.ts +++ b/course/09-turn-pipeline/tests/ts/test_09.test.ts @@ -437,3 +437,89 @@ test("test_09_hazard_order_sink_before_melt", () => { expect(b.posOf(key)).toBeNull(); expect(b.posOf(water)).toBeNull(); }); + +/** Everything the harness shows: every tile's kinds and stack, where each id up to `lastId` is, the rules, the outcome. */ +function view(b: Baba, width: number, height: number, lastId: number) { + const tiles: [string[], string[]][] = []; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) tiles.push([b.kindsAt(x, y), b.stackAt(x, y)]); + const positions = Array.from({ length: lastId }, (_, i) => b.posOf(i + 1)); + return { tiles, positions, rules: b.rules(), active: b.activeRules(), won: b.won(), lost: b.lost() }; +} + +test("test_09_undo_restores_everything", () => { + const b = newBaba(emptyLevel(5, 5)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["KEY", 0, 1], + ["IS", 1, 1], + ["ROCK", 2, 1], + ["AND", 3, 1], + ["FLAG", 4, 1], + ]); + write(b, [ + ["ROCK", 0, 2], + ["IS", 1, 2], + ["MELT", 2, 2], + ]); + write(b, [ + ["LAVA", 0, 3], + ["IS", 1, 3], + ["HOT", 2, 3], + ]); + write(b, [ + ["FLAG", 0, 4], + ["IS", 1, 4], + ]); + const winText = b.place("text:WIN", 3, 4); + const key = b.place("key", 3, 4); + b.place("lava", 3, 4); + const baba = b.place("baba", 4, 4); + expect(baba).toBe(20); + const before = view(b, 5, 5, 20); + + b.step("left"); + + // The turn moved Baba and the word WIN, converted the key, melted it and won. + expect(b.posOf(baba)).toEqual([3, 4]); + expect(b.posOf(winText)).toEqual([2, 4]); + expect(b.posOf(key)).toBeNull(); + expect(b.stackAt(3, 4)).toEqual(["lava", "baba", "flag"]); + expect(b.posOf(21)).toEqual([3, 4]); + expect(b.rules()).toContain("FLAG IS WIN"); + expect(b.won()).toBe(true); + const after = view(b, 5, 5, 21); + + expect(b.undo()).toBe(true); + + expect(view(b, 5, 5, 20)).toEqual(before); + + b.step("left"); + + // The same step gives the same result, ids included: the new flag is 21 again. + expect(view(b, 5, 5, 21)).toEqual(after); +}); + +test("test_09_undo_walks_back_and_stops", () => { + const b = newBaba(emptyLevel(4, 3)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + const baba = b.place("baba", 0, 2); + + b.step("right"); + b.step("right"); + + expect(b.posOf(baba)).toEqual([2, 2]); + expect(b.undo()).toBe(true); + expect(b.posOf(baba)).toEqual([1, 2]); + expect(b.undo()).toBe(true); + expect(b.posOf(baba)).toEqual([0, 2]); + expect(b.undo()).toBe(false); + expect(b.posOf(baba)).toEqual([0, 2]); +}); diff --git a/course/10-spellgrid/reference/rust/README.md b/course/10-spellgrid/reference/rust/README.md index 688a014..f12c68b 100644 --- a/course/10-spellgrid/reference/rust/README.md +++ b/course/10-spellgrid/reference/rust/README.md @@ -14,7 +14,7 @@ The course's own engine as of the end of Module 10: Part I's card engine and Par - `level.rs`: `Destroy` and `remove_all`. `step_with` and `move_you_with` take a destroy function, which movement, conversion (`X IS NOT X`) and hazards hand their victims to; `step` and `move_you` pass `remove_all`. `reparse` and `board_mut` are public. - `grid.rs`: `Board::reserve_id`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid), with `LevelCard`. - `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. -- `src/harness.rs`: Module 10 adds `new_spell_grid` and `SpellGrid`, including the test hook `with_naive_aura_timing()`, which reverses `END_OF_PHASE` for one game. +- `src/harness.rs`: Module 10 adds `new_spell_grid` and `SpellGrid`, including the test hook `with_naive_aura_timing()`, which reverses `END_OF_PHASE` for one game. Its `undo` panics with `not supported`: Part I has state the level's snapshot doesn't cover. - `src/cli.rs` and `src/bin/spellgrid.rs` (new): the command line, `cargo run --bin spellgrid -- levels/l1-double-tap.json` run from a staged directory (it needs `cards/` and `levels/`): `node tools/play-spellgrid.mjs rust l1` from the course repository stages one in a temporary directory and starts it. `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.rs` plays it from a script; it is the reference's own check, not a course test. Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs index da06746..ea2569b 100644 --- a/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs @@ -8,10 +8,15 @@ //! 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 function the //! caller may pass in (`step_with`, `move_you_with`). The default, `remove_all`, //! removes them. SpellGrid passes one that sends minions to Part I's Death Creation -//! Step instead. +//! Step instead. SpellGrid doesn't offer undo: Part I has state the level's +//! snapshot doesn't cover. use std::collections::BTreeSet; @@ -31,6 +36,16 @@ pub struct Level { rules: Vec, active: Vec, won: bool, + /// What `undo` puts back: one entry per move or turn, the latest last (Module 9). + history: Vec, +} + +/// The level before a move or a turn: the whole board (its objects, oldest first, +/// and its id counter) and `won`. +#[derive(Debug, Clone)] +struct Snapshot { + board: Board, + won: bool, } /// The property that makes an object move when the player moves. @@ -69,6 +84,7 @@ impl Level { rules: vec![], active: vec![], won: false, + history: vec![], }; for o in &def.objects { level.board.place(Kind::parse(&o.kind), o.x, o.y); @@ -79,6 +95,7 @@ impl Level { pub fn place(&mut self, kind: &str, x: i32, y: i32) -> ObjectId { let id = self.board.place(Kind::parse(kind), x, y); + self.history.clear(); self.reparse(); id } @@ -154,6 +171,7 @@ impl Level { /// `move_you`, with destroyed objects going to `destroy` (Module 10). pub fn move_you_with(&mut self, dir: Dir, destroy: &mut Destroy) { + self.save(); self.movement(dir, destroy); self.reparse(); } @@ -165,6 +183,7 @@ impl Level { /// `step`, with destroyed objects going to `destroy` (Module 10). pub fn step_with(&mut self, dir: Dir, destroy: &mut Destroy) { + self.save(); for phase in TURN { match phase { Phase::Movement => self.movement(dir, destroy), @@ -176,6 +195,26 @@ impl Level { } } + /// Put the level back as it was before the last move or turn (Module 9), and + /// reparse. False, and no change, if there is none; `place` forgets them all. + pub fn undo(&mut self) -> bool { + let Some(last) = self.history.pop() else { + return false; + }; + self.board = last.board; + self.won = last.won; + self.reparse(); + true + } + + fn save(&mut self) { + let snapshot = Snapshot { + board: self.board.clone(), + won: self.won, + }; + self.history.push(snapshot); + } + /// 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 diff --git a/course/10-spellgrid/reference/rust/src/harness.rs b/course/10-spellgrid/reference/rust/src/harness.rs index 9c9ec48..4e528eb 100644 --- a/course/10-spellgrid/reference/rust/src/harness.rs +++ b/course/10-spellgrid/reference/rust/src/harness.rs @@ -317,6 +317,12 @@ impl Baba { kinds } + /// The kinds on tile (x, y), as `kinds_at`, but oldest object first. + pub fn stack_at(&self, x: i32, y: i32) -> Vec { + let board = self.level.board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } + // ---- Module 9 ---- /// Play one whole turn: movement, parse, conversion, parse, hazards, win @@ -334,6 +340,12 @@ impl Baba { pub fn lost(&self) -> bool { self.level.lost() } + + /// Put the level back as it was before the last `step` or `move_dir`. False, and + /// no change, if there is none; repeated calls walk further back. + pub fn undo(&mut self) -> bool { + self.level.undo() + } } fn normalised(rules: &[puzzle::Rule]) -> Vec { @@ -402,6 +414,10 @@ impl SpellGrid { kinds.sort(); kinds } + pub fn stack_at(&self, x: i32, y: i32) -> Vec { + let board = self.grid.level().board(); + board.at(x, y).map(|o| o.kind.to_string()).collect() + } pub fn step(&mut self, dir: Dir) { self.grid.step(dir); } @@ -411,6 +427,11 @@ impl SpellGrid { pub fn lost(&self) -> bool { self.grid.level().lost() } + /// Not supported: Part I has state (health, hands, the log) that the level's + /// snapshot doesn't cover. Panics. + pub fn undo(&mut self) -> bool { + panic!("undo is not supported in SpellGrid"); + } // ---- Module 10 ---- diff --git a/course/10-spellgrid/reference/ts/README.md b/course/10-spellgrid/reference/ts/README.md index f57b624..890765a 100644 --- a/course/10-spellgrid/reference/ts/README.md +++ b/course/10-spellgrid/reference/ts/README.md @@ -14,7 +14,7 @@ The course's own engine as of the end of Module 10: Part I's card engine and Par - `level.ts`: `Destroy` and `removeAll`. `step` and `moveYou` take an optional `Destroy`, which movement, conversion (`X IS NOT X`) and hazards hand their victims to. `reparse` is public. - `grid.ts`: `Board.reserveId`, and `cards`/`hand` in `LevelDef` (read only by SpellGrid). - `src/content/seams.json` and `seams()` (new): which nouns name a trigger (`DEATHRATTLE`) and which properties multiply one (`DOUBLE`, twice, `stacking: "max"`). `words.json` adds the SpellGrid nouns, `DEATHRATTLE` and `DOUBLE`. `index.ts` now reads `cards/` with `node:fs`, so the command line, which runs without Vite, reads the same cards. -- `src/harness.ts`: Module 10 adds `newSpellGrid` and the `SpellGrid` interface, including the test hook `withNaiveAuraTiming()`, which reverses `END_OF_PHASE` for one game. +- `src/harness.ts`: Module 10 adds `newSpellGrid` and the `SpellGrid` interface, including the test hook `withNaiveAuraTiming()`, which reverses `END_OF_PHASE` for one game. Its `undo` throws `not supported`: Part I has state the level's snapshot doesn't cover. - `src/cli.ts` (new): the command line, `npx tsx src/cli.ts levels/l1-double-tap.json` run from a staged directory (it needs `cards/` and `levels/`): `node tools/play-spellgrid.mjs ts l1` from the course repository stages one in a temporary directory and starts it. `w a s d` move, `c [x y]` casts a spell (at the minion on that tile) or plays a minion (onto that empty tile), `q` quits. `tests/cli.test.ts` plays it from a script; it is the reference's own check, not a course test. Run it with the module's tests: `node tools/verify-references.mjs 10` from the course repository. diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts index 9cfa771..91feb4d 100644 --- a/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/grid.ts @@ -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(); 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; + } } diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts index d1ed406..30261bf 100644 --- a/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts @@ -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, diff --git a/course/10-spellgrid/reference/ts/src/harness.ts b/course/10-spellgrid/reference/ts/src/harness.ts index 771ed8a..ccb72c4 100644 --- a/course/10-spellgrid/reference/ts/src/harness.ts +++ b/course/10-spellgrid/reference/ts/src/harness.ts @@ -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), diff --git a/course/harness.md b/course/harness.md index 757c660..cfdbce1 100644 --- a/course/harness.md +++ b/course/harness.md @@ -80,8 +80,8 @@ In TypeScript each event is a plain object with a `type` field, for example `{ t - M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, each distinct rule once, includes `"TEXT IS PUSH"`). - M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass; same format as `rules()`). From M7, `rules()` also lists negated rules (`"BABA IS NOT YOU"`). -- M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted). -- M9 (requires the M7 Expansion Drop: the meta-noun `ALL` is in the word list): `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain). +- M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted); `stackAt(x, y): string[]` (the same kinds, oldest first). +- M9 (requires the M7 Expansion Drop: the meta-noun `ALL` is in the word list): `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain); `undo(): boolean` (back to before the last `step` or `move`; false if there is none). Grid coordinates: `x` grows right, `y` grows down; vertical sentences read top-to-bottom. @@ -97,8 +97,10 @@ Grid coordinates: `x` grows right, `y` grows down; vertical sentences read top-t | M8 | `move(dir)` | `move_dir(&mut self, dir: Dir)` (`move` is a Rust keyword) | | M8 | `posOf(id)` | `pos_of(&self, id: Id) -> Option<(i32, i32)>` | | M8 | `kindsAt(x, y)` | `kinds_at(&self, x: i32, y: i32) -> Vec` | +| M8 | `stackAt(x, y)` | `stack_at(&self, x: i32, y: i32) -> Vec` | | M9 | `step(dir)` | `step(&mut self, dir: Dir)` | | M9 | `won()`, `lost()` | `won(&self) -> bool`, `lost(&self) -> bool` | +| M9 | `undo()` | `undo(&mut self) -> bool` | `LevelDef` is the level format of `course/schema/level.schema.json`. In TypeScript it is `{ width, height, objects: { kind, x, y }[] }`. In Rust it is defined in `src/harness.rs` (or re-exported there) and implements `Default`, so tests write `LevelDef { width: 3, height: 3, ..Default::default() }` and keep compiling when Part III adds `cards`: @@ -123,7 +125,7 @@ pub struct LevelObject { From M7, `rules()` is what the text spells before resolution, and `activeRules()` is the rule set in force after it: `X IS NOT Y` cancels every `X IS Y` and stays in the set itself; a surviving `X IS X` then cancels every conversion `X IS Y` (`Y` a noun other than `X`); `"TEXT IS PUSH"` is listed unless `TEXT IS NOT PUSH` cancels it. `X IS NOT X` stays in `activeRules()` as the record that every X is to vanish (the reference's choice; no test checks it; the objects are removed by the M9 turn). `hasProperty(id, prop)` is true if an active rule gives `prop` to a noun that names the object and no active negated rule takes it from one: a thing is named by its own noun, a text object by `TEXT` (and, after the M7 Expansion Drop, every thing by `ALL`). `hasProperty` on an id the level never issued panics (Rust) or throws (TypeScript). -From M8, `move(dir)` moves every object that is `YOU` one tile, with the objects it pushes, and then reparses the rules once. It is movement only: conversions, hazards and winning are the M9 turn. An object steps at most once per move: a `YOU` object that an earlier `YOU` object's move already carried along (pushed, or pulled) doesn't start a move of its own, and isn't moved again. Each `YOU` object's move is planned in full before anything changes: if any object in a push chain can't move (`STOP`, or the edge of the level, which blocks like `STOP`), nothing in that chain moves, the `YOU` object included. An `OPEN` object moving onto a `SHUT` one (or the reverse), pushed or not, destroys both during the move. Several `YOU` objects move one at a time, the one furthest ahead in `dir` first (the reference's choice). `posOf(id)` is `[x, y]`, or `null` once the object has been destroyed (Rust: `Option<(i32, i32)>`); it panics or throws on an id the level never issued. `kindsAt(x, y)` lists the kinds on a tile as they are placed (`"baba"`, `"text:ROCK"`), sorted; `[]` for an empty tile. From M8, `hasProperty` on a destroyed object is `false`. +From M8, `move(dir)` moves every object that is `YOU` one tile, with the objects it pushes, and then reparses the rules once. It is movement only: conversions, hazards and winning are the M9 turn. An object steps at most once per move: a `YOU` object that an earlier `YOU` object's move already carried along (pushed, or pulled) doesn't start a move of its own, and isn't moved again. Each `YOU` object's move is planned in full before anything changes: if any object in a push chain can't move (`STOP`, or the edge of the level, which blocks like `STOP`), nothing in that chain moves, the `YOU` object included. An `OPEN` object moving onto a `SHUT` one (or the reverse), pushed or not, destroys both during the move. Several `YOU` objects move one at a time, the one furthest ahead in `dir` first (the reference's choice). `posOf(id)` is `[x, y]`, or `null` once the object has been destroyed (Rust: `Option<(i32, i32)>`); it panics or throws on an id the level never issued. `kindsAt(x, y)` lists the kinds on a tile as they are placed (`"baba"`, `"text:ROCK"`), sorted; `[]` for an empty tile. `stackAt(x, y)` lists the same kinds oldest first: an object's age is the order in which objects were placed (a conversion keeps the object's age; an object a conversion adds is the newest), not when it arrived on the tile. From M8, `hasProperty` on a destroyed object is `false`. In TypeScript a direction is the string `"up" | "down" | "left" | "right"` (exported as the type `Dir`). In Rust it is an enum, defined in the engine and re-exported from `src/harness.rs`: @@ -137,18 +139,19 @@ pub enum Dir { } ``` -From M9, `step(dir)` plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that `rules()` matches the board. Conversion: every object named by an active `X IS Y` (`Y` a noun naming its own things) becomes a `Y` in place, keeping its id, unless an active `… IS NOT Y` also names the object (negation is settled per object, as in `hasProperty`); with several `Y`s it becomes the first and each other `Y` is a new object on its tile; `X IS X` blocks it; an object named by `X IS NOT X` is deleted (its other `Y`s still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: `SINK`, `HOT`/`MELT`, `DEFEAT`); each sees only the objects that share a tile on the same float level (both `FLOAT` or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. `won()` becomes `true` when the win check finds a `YOU` object that is `WIN`, or shares its tile with a same-float `WIN` object, and stays `true`. `lost()` is `true` whenever no object is `YOU`. `move(dir)` keeps its M8 meaning. +From M9, `step(dir)` plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that `rules()` matches the board. Conversion: every object named by an active `X IS Y` (`Y` a noun naming its own things) becomes a `Y` in place, keeping its id, unless an active `… IS NOT Y` also names the object (negation is settled per object, as in `hasProperty`); with several `Y`s it becomes the first and each other `Y` is a new object on its tile; `X IS X` blocks it; an object named by `X IS NOT X` is deleted (its other `Y`s still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: `SINK`, `HOT`/`MELT`, `DEFEAT`); each sees only the objects that share a tile on the same float level (both `FLOAT` or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. `won()` becomes `true` when the win check finds a `YOU` object that is `WIN`, or shares its tile with a same-float `WIN` object, and stays `true`. `lost()` is `true` whenever no object is `YOU`. `move(dir)` keeps its M8 meaning. `undo()` puts the level back as it was before the last `step` or `move` and returns `true`; repeated calls walk further back; with nothing to undo it returns `false` and changes nothing. Afterwards every `posOf`, `kindsAt`, `stackAt`, `rules()`, `activeRules()` and `won()` is as it was, and the next new object gets the id it would have got then: a snapshot holds every object's id, kind and position in age order, the id counter and `won` (the rules are recompiled, not saved). `place` clears the history. ## Part III (`SpellGrid`) -- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast(card, target?)`, `play(card, x, y): Id`, `health(id)`, `hero(player)`, `nameOf(id)`, `minionAt(x, y): Id | null`, `hand(): string[]`, `log()`, `clearLog()`, `logTree()`, `setChooser(choose)`, `won()`, and the test hook `withNaiveAuraTiming()`. Minions are grid objects whose noun is their card's `"noun"` field. +- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II (except `undo`, which throws `not supported`: Part I has state the level's snapshot doesn't cover) plus `cast(card, target?)`, `play(card, x, y): Id`, `health(id)`, `hero(player)`, `nameOf(id)`, `minionAt(x, y): Id | null`, `hand(): string[]`, `log()`, `clearLog()`, `logTree()`, `setChooser(choose)`, `won()`, and the test hook `withNaiveAuraTiming()`. Minions are grid objects whose noun is their card's `"noun"` field. Rust: `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid`. | TypeScript | Rust (`impl SpellGrid`) | |---|---| | `newSpellGrid(level, cards)` | `new_spell_grid(level: LevelDef, cards: Vec) -> SpellGrid` (free function) | -| Part II: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost` | the same as on `Baba` (`move_dir` for `move`) | +| Part II: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `stackAt`, `step`, `won`, `lost` | the same as on `Baba` (`move_dir` for `move`) | +| `undo()` | `undo(&mut self) -> bool`: not supported; throws (Rust: panics) with a message containing `not supported` | | `cast(card, target?)` | `cast(&mut self, card: &str, target: Option)` | | `play(card, x, y): Id` | `play(&mut self, card: &str, x: i32, y: i32) -> Id` | | `health(id)`, `hero(player)`, `nameOf(id)` | `health(&self, id: Id) -> i32`, `hero(&self, player: Player) -> Id`, `name_of(&self, id: Id) -> String` |