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:
1 parent
21fb40c6f5
commit
820b378929
27 files changed
+530
-30
No files matched your search
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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 <card> [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.
|
||||
@@ -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<ObjectId, Obj>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user