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

+1 -1
View File
@@ -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 <card> [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.
@@ -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<Rule>,
active: Vec<Rule>,
won: bool,
/// What `undo` puts back: one entry per move or turn, the latest last (Module 9).
history: Vec<Snapshot>,
}
/// 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
@@ -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<String> {
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<String> {
@@ -402,6 +414,10 @@ impl SpellGrid {
kinds.sort();
kinds
}
pub fn stack_at(&self, x: i32, y: i32) -> Vec<String> {
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 ----