From 8467c7929eccfe804d446398c8602d797d2f1dcd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 11 Nov 2026 09:18:00 +0100 Subject: [PATCH] fix(puzzle): an object steps once per movement phase; negated conversion is settled per object Bug 1 (modules 08-10): a YOU object pulled by an earlier mover took a second step of its own. Level movement now keeps the ids that stepped in a committed plan this phase; such a YOU object starts no move, and Planner try_move answers no for it. Regression: test_08x_pulled_you_moves_once (Module 8 Expansion Drop). Bug 2 (modules 09-10): convert honoured negation only per rule, so ALL IS ROCK beat BABA IS NOT ROCK. A target Y is now skipped for an object if any active negated rule IS NOT Y names it. Module 9 and 10 references (and their drop solutions) now carry ALL, the Module 7 Expansion Drop. Regression: test_09_negated_conversion_beats_group. Co-Authored-By: Claude Fable 5.2 --- course/08-push-chains/expansion/README.md | 6 ++++ .../expansion/tests/rust/test_08x.rs | 22 ++++++++++++++ .../expansion/tests/ts/test_08x.test.ts | 27 +++++++++++++++++ .../08-push-chains/reference/rust/README.md | 4 +-- .../reference/rust/src/engine/puzzle/level.rs | 13 +++++++- .../rust/src/engine/puzzle/movement.rs | 15 ++++++++-- course/08-push-chains/reference/ts/README.md | 4 +-- .../reference/ts/src/engine/puzzle/level.ts | 13 ++++++-- .../ts/src/engine/puzzle/movement.ts | 12 ++++++-- .../expansion/solution/words.json | 1 + .../09-turn-pipeline/reference/rust/README.md | 4 +-- .../reference/rust/src/content/words.json | 1 + .../reference/rust/src/engine/puzzle/level.rs | 21 ++++++++++++- .../rust/src/engine/puzzle/movement.rs | 15 ++++++++-- .../09-turn-pipeline/reference/ts/README.md | 4 +-- .../reference/ts/src/content/words.json | 1 + .../reference/ts/src/engine/puzzle/level.ts | 18 +++++++++-- .../ts/src/engine/puzzle/movement.ts | 12 ++++++-- course/09-turn-pipeline/scenarios.md | 11 +++++++ course/09-turn-pipeline/tests/rust/test_09.rs | 22 ++++++++++++++ .../09-turn-pipeline/tests/ts/test_09.test.ts | 30 +++++++++++++++++++ .../expansion/solution/words.json | 1 + .../reference/rust/src/content/words.json | 1 + .../reference/rust/src/engine/puzzle/level.rs | 21 ++++++++++++- .../rust/src/engine/puzzle/movement.rs | 15 ++++++++-- .../reference/ts/src/content/words.json | 1 + .../reference/ts/src/engine/puzzle/level.ts | 18 +++++++++-- .../ts/src/engine/puzzle/movement.ts | 12 ++++++-- 28 files changed, 293 insertions(+), 32 deletions(-) diff --git a/course/08-push-chains/expansion/README.md b/course/08-push-chains/expansion/README.md index f3f71e5..65f7742 100644 --- a/course/08-push-chains/expansion/README.md +++ b/course/08-push-chains/expansion/README.md @@ -23,3 +23,9 @@ The reference lets a PULL object that can't follow stay behind without stopping - Assertions 2: nothing moved: Baba is still at (3,2), the near rock at (2,2) and the far rock at (1,2). A PULL object is solid: Baba can't step onto it, and PULL doesn't push. Before the drop the lexicon doesn't know `PULL`, so `ROCK IS PULL` spells nothing: Baba walks away from the rocks, and in the second move walks back onto the near rock's tile. + +### test_08x_pulled_you_moves_once + +- Setup: `newBaba` with an empty level 5 wide and 4 high. Place `BABA (0,0) IS (1,0) YOU (2,0)` and `KEY (0,1) IS (1,1) YOU (2,1) AND (3,1) PULL (4,1)`. Place an object `key` at (1,3) and an object `baba` at (2,3). +- Action: `move("right")`. +- Assertions: `posOf(baba)` is (3,3) and `posOf(key)` is (2,3). Baba is furthest ahead, so it moves first, and the key behind it follows. The key is `YOU` as well, but it has already stepped in this move, and an object steps at most once per move: it doesn't take a second step of its own onto Baba's tile. diff --git a/course/08-push-chains/expansion/tests/rust/test_08x.rs b/course/08-push-chains/expansion/tests/rust/test_08x.rs index 0f205ac..8b8259c 100644 --- a/course/08-push-chains/expansion/tests/rust/test_08x.rs +++ b/course/08-push-chains/expansion/tests/rust/test_08x.rs @@ -30,3 +30,25 @@ fn test_08x_pull_follows() { assert_eq!(b.pos_of(near_rock), Some((2, 2))); assert_eq!(b.pos_of(far_rock), Some((1, 2))); } + +#[test] +fn test_08x_pulled_you_moves_once() { + let mut b = new_baba(LevelDef { + width: 5, + height: 4, + ..Default::default() + }); + for (word, x) in [("BABA", 0), ("IS", 1), ("YOU", 2)] { + b.place(&format!("text:{word}"), x, 0); + } + for (word, x) in [("KEY", 0), ("IS", 1), ("YOU", 2), ("AND", 3), ("PULL", 4)] { + b.place(&format!("text:{word}"), x, 1); + } + let key = b.place("key", 1, 3); + let baba = b.place("baba", 2, 3); + + b.move_dir(Dir::Right); + + assert_eq!(b.pos_of(baba), Some((3, 3))); + assert_eq!(b.pos_of(key), Some((2, 3))); +} diff --git a/course/08-push-chains/expansion/tests/ts/test_08x.test.ts b/course/08-push-chains/expansion/tests/ts/test_08x.test.ts index 13663a9..3a4b319 100644 --- a/course/08-push-chains/expansion/tests/ts/test_08x.test.ts +++ b/course/08-push-chains/expansion/tests/ts/test_08x.test.ts @@ -34,3 +34,30 @@ test("test_08x_pull_follows", () => { expect(b.posOf(nearRock)).toEqual([2, 2]); expect(b.posOf(farRock)).toEqual([1, 2]); }); + +test("test_08x_pulled_you_moves_once", () => { + const b = newBaba({ width: 5, height: 4, objects: [] }); + for (const [word, x] of [ + ["BABA", 0], + ["IS", 1], + ["YOU", 2], + ] as const) { + b.place(`text:${word}`, x, 0); + } + for (const [word, x] of [ + ["KEY", 0], + ["IS", 1], + ["YOU", 2], + ["AND", 3], + ["PULL", 4], + ] as const) { + b.place(`text:${word}`, x, 1); + } + const key = b.place("key", 1, 3); + const baba = b.place("baba", 2, 3); + + b.move("right"); + + expect(b.posOf(baba)).toEqual([3, 3]); + expect(b.posOf(key)).toEqual([2, 3]); +}); diff --git a/course/08-push-chains/reference/rust/README.md b/course/08-push-chains/reference/rust/README.md index a752db1..ec35831 100644 --- a/course/08-push-chains/reference/rust/README.md +++ b/course/08-push-chains/reference/rust/README.md @@ -5,8 +5,8 @@ The course's own engine as of the end of Module 8. 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`, and from Module 8 the property `YOU`, which picks the objects that move. - `grid.rs`: the level format, object kinds and the board. Module 8 adds `Board::find`, `Board::remove`, `Board::shift`, `Board::at` and `Display` for `Kind` (the kind as placed, for `kinds_at`). - - `movement.rs` (new): validate, then commit. `Planner` (`try_move`, `staying_at`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step. - - `level.rs`: a level in play. Module 8 adds `Level::move_you`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed; the rules are reparsed once at the end. `has_property` is `false` for a destroyed object. + - `movement.rs` (new): validate, then commit. `Planner` (`try_move`, `staying_at`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step, and an object that has already stepped in the same move can't step again. + - `level.rs`: a level in play. Module 8 adds `Level::move_you`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed, and an object steps at most once per move (a `YOU` object that an earlier move carried along doesn't start its own); the rules are reparsed once at the end. `has_property` is `false` for a destroyed object. - `word.rs`, `parse.rs`, `resolve.rs`: unchanged. - `mod.rs`: the module's exports. - The rest of `src/engine/`: Part I, unchanged. diff --git a/course/08-push-chains/reference/rust/src/engine/puzzle/level.rs b/course/08-push-chains/reference/rust/src/engine/puzzle/level.rs index 1f7656e..ae9345e 100644 --- a/course/08-push-chains/reference/rust/src/engine/puzzle/level.rs +++ b/course/08-push-chains/reference/rust/src/engine/puzzle/level.rs @@ -5,6 +5,8 @@ //! A move (Module 8) is planned for each `YOU` object, then committed; the rules are //! reparsed once, after all of them. +use std::collections::BTreeSet; + use super::grid::{Board, Kind, LevelDef, ObjectId}; use super::movement::{Dir, Plan, Planner, Response}; use super::parse::{parse, Rule}; @@ -86,6 +88,10 @@ impl Level { /// 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 first. + /// + /// An object steps at most once in a movement phase. A `YOU` object that an + /// earlier move already carried along (pushed, or moved by any other response) + /// doesn't start a move of its own, and no later move can make it step again. pub fn move_you(&mut self, dir: Dir) { let (dx, dy) = dir.delta(); let mut you: Vec<_> = self @@ -95,14 +101,19 @@ impl Level { .map(|o| (-(o.x * dx + o.y * dy), o.id)) .collect(); you.sort(); + let mut stepped = BTreeSet::new(); for (_, id) in you { if self.board.find(id).is_none() { continue; // destroyed by an earlier move } - let mut planner = Planner::new(self, dir); + if stepped.contains(&id) { + continue; // already moved, by an earlier move + } + let mut planner = Planner::new(self, dir, &stepped); if planner.try_move(id) { let plan = planner.into_plan(); self.commit(&plan, dir); + stepped.extend(plan.moving.difference(&plan.destroyed)); } } self.reparse(); diff --git a/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs b/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs index 4d0a381..fb71ef5 100644 --- a/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs +++ b/course/08-push-chains/reference/rust/src/engine/puzzle/movement.rs @@ -59,18 +59,22 @@ pub struct Plan { pub destroyed: BTreeSet, } -/// Plans one move on a board it only reads. +/// Plans one move on a board it only reads. `stepped` holds the objects that have +/// already stepped in this movement phase, in an earlier committed plan: an object +/// steps at most once per phase, so none of them can step again. pub struct Planner<'a> { level: &'a Level, dir: Dir, + stepped: &'a BTreeSet, plan: Plan, } impl<'a> Planner<'a> { - pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> { + pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet) -> Planner<'a> { Planner { level, dir, + stepped, plan: Plan::default(), } } @@ -100,11 +104,16 @@ impl<'a> Planner<'a> { /// Plan object `id` stepping one tile in the move's direction, with whatever /// the responses add. Returns whether it can. If it can't, the plan is left as /// it was before the call. An object already planned to move, or to be - /// destroyed, is out of the way: the answer is yes, and nothing is added. + /// destroyed, is out of the way: the answer is yes, and nothing is added. An + /// object that has already stepped in this movement phase can't step again: the + /// answer is no. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } + if self.stepped.contains(&id) { + return false; + } let level = self.level; let o = level.board().get(id); let (dx, dy) = self.dir.delta(); diff --git a/course/08-push-chains/reference/ts/README.md b/course/08-push-chains/reference/ts/README.md index 749018b..c77694e 100644 --- a/course/08-push-chains/reference/ts/README.md +++ b/course/08-push-chains/reference/ts/README.md @@ -5,8 +5,8 @@ The course's own engine as of the end of Module 8. 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`, and from Module 8 the property `YOU`, which picks the objects that move. - `grid.ts`: the level format, object kinds and the board. Module 8 adds `Board.find`, `Board.remove`, `Board.shift`, `Board.at` and `Kind.format` (the kind as placed, for `kindsAt`). - - `movement.ts` (new): validate, then commit. `Planner` (`tryMove`, `stayingAt`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step. - - `level.ts`: a level in play. Module 8 adds `Level.moveYou`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed; the rules are reparsed once at the end. `hasProperty` is `false` for a destroyed object. + - `movement.ts` (new): validate, then commit. `Planner` (`tryMove`, `stayingAt`, `destroy`, `has`), `Step`, `Plan`, `Response` and `Dir`. The planner reads the board and plans one step at a time; for each step it runs content's movement responses in order, and rolls back everything a step added if a response blocks it. The edge of the level blocks every step, and an object that has already stepped in the same move can't step again. + - `level.ts`: a level in play. Module 8 adds `Level.moveYou`: the `YOU` objects move one at a time, furthest ahead first, each planned and then committed, and an object steps at most once per move (a `YOU` object that an earlier move carried along doesn't start its own); the rules are reparsed once at the end. `hasProperty` is `false` for a destroyed object. - `word.ts`, `parse.ts`, `resolve.ts`: unchanged. - `index.ts`: the module's exports. - The rest of `src/engine/`: Part I, unchanged. diff --git a/course/08-push-chains/reference/ts/src/engine/puzzle/level.ts b/course/08-push-chains/reference/ts/src/engine/puzzle/level.ts index e218e94..d36dcfc 100644 --- a/course/08-push-chains/reference/ts/src/engine/puzzle/level.ts +++ b/course/08-push-chains/reference/ts/src/engine/puzzle/level.ts @@ -74,6 +74,10 @@ export class Level { * 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 first. + * + * An object steps at most once in a movement phase. A `YOU` object that an + * earlier move already carried along (pushed, or moved by any other response) + * doesn't start a move of its own, and no later move can make it step again. */ moveYou(dir: Dir): void { const [dx, dy] = delta(dir); @@ -83,10 +87,15 @@ export class Level { .filter((o) => this.hasProperty(o.id, YOU)) .sort((a, b) => ahead(b) - ahead(a) || a.id - b.id) .map((o) => o.id); + const stepped = new Set(); for (const id of you) { if (this.board.find(id) === undefined) continue; // destroyed by an earlier move - const planner = new Planner(this, dir); - if (planner.tryMove(id)) this.commit(planner.result(), dir); + if (stepped.has(id)) continue; // already moved, by an earlier move + const planner = new Planner(this, dir, stepped); + if (!planner.tryMove(id)) continue; + const plan = planner.result(); + this.commit(plan, dir); + for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved); } this.reparse(); } diff --git a/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts b/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts index 36fc15b..9c0e51a 100644 --- a/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts +++ b/course/08-push-chains/reference/ts/src/engine/puzzle/movement.ts @@ -63,13 +63,18 @@ export interface MoveContext { responses(): readonly Response[]; } -/** Plans one move on a board it only reads. */ +/** + * Plans one move on a board it only reads. `stepped` holds the objects that have + * already stepped in this movement phase, in an earlier committed plan: an object + * steps at most once per phase, so none of them can step again. + */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; constructor( private readonly level: MoveContext, private readonly dir: Dir, + private readonly stepped: ReadonlySet, ) {} /** Does object `id` have `property`, under the rules in force before the move? */ @@ -98,10 +103,13 @@ export class Planner { * Plan object `id` stepping one tile in the move's direction, with whatever * the responses add. Returns whether it can. If it can't, the plan is left as * it was before the call. An object already planned to move, or to be - * destroyed, is out of the way: the answer is yes, and nothing is added. + * destroyed, is out of the way: the answer is yes, and nothing is added. An + * object that has already stepped in this movement phase can't step again: the + * answer is no. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; + if (this.stepped.has(id)) return false; const o = this.level.board.get(id); const [dx, dy] = delta(this.dir); const to: Pos = [o.x + dx, o.y + dy]; diff --git a/course/09-turn-pipeline/expansion/solution/words.json b/course/09-turn-pipeline/expansion/solution/words.json index 784270c..d26958b 100644 --- a/course/09-turn-pipeline/expansion/solution/words.json +++ b/course/09-turn-pipeline/expansion/solution/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "YOU", "role": "property" }, { "name": "WIN", "role": "property" }, { "name": "PUSH", "role": "property" }, diff --git a/course/09-turn-pipeline/reference/rust/README.md b/course/09-turn-pipeline/reference/rust/README.md index aebfe63..d7367f0 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); 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. - `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. @@ -13,7 +13,7 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - The rest of `src/engine/`: Part I, unchanged. - `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`. +- `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`. 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/content/words.json b/course/09-turn-pipeline/reference/rust/src/content/words.json index e1498be..e859dd7 100644 --- a/course/09-turn-pipeline/reference/rust/src/content/words.json +++ b/course/09-turn-pipeline/reference/rust/src/content/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "YOU", "role": "property" }, { "name": "WIN", "role": "property" }, { "name": "PUSH", "role": "property" }, 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 c45c998..df6a593 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 @@ -8,6 +8,8 @@ //! A turn (Module 9) runs the phases of `TURN` in order: movement, parse, //! conversion, parse, content's hazards, the win check, parse. +use std::collections::BTreeSet; + use super::grid::{Board, Kind, LevelDef, ObjectId}; use super::movement::{Dir, Plan, Planner, Response}; use super::parse::{parse, Rule}; @@ -146,6 +148,10 @@ impl Level { /// (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 /// first. + /// + /// An object steps at most once in a movement phase. A `YOU` object that an + /// earlier move already carried along (pushed, or moved by any other response) + /// doesn't start a move of its own, and no later move can make it step again. fn movement(&mut self, dir: Dir) { let (dx, dy) = dir.delta(); let mut you: Vec<_> = self @@ -155,14 +161,19 @@ impl Level { .map(|o| (-(o.x * dx + o.y * dy), o.id)) .collect(); you.sort(); + let mut stepped = BTreeSet::new(); for (_, id) in you { if self.board.find(id).is_none() { continue; // destroyed by an earlier move } - let mut planner = Planner::new(self, dir); + if stepped.contains(&id) { + continue; // already moved, by an earlier move + } + let mut planner = Planner::new(self, dir, &stepped); if planner.try_move(id) { let plan = planner.into_plan(); self.commit(&plan, dir); + stepped.extend(plan.moving.difference(&plan.destroyed)); } } } @@ -182,6 +193,10 @@ impl Level { /// object blocks this. An object named by `X IS NOT X` is deleted, and still /// makes its `Y`s, as new objects: the negation cancelled `X IS X`. /// + /// Negation is settled per object, as in `has_property`: an object doesn't + /// become a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + /// conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X). + /// /// Every object is decided from the board as movement left it, before any /// changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. fn convert(&mut self) { @@ -197,6 +212,10 @@ impl Level { let mut into: Vec = vec![]; if !locked { for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) { + let negated = |n: &&Rule| n.negated && n.complement == r.complement; + if about_it.iter().any(negated) { + continue; + } match self.lexicon.thing_named(&r.complement) { Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind), _ => {} diff --git a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs index 4d0a381..fb71ef5 100644 --- a/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs +++ b/course/09-turn-pipeline/reference/rust/src/engine/puzzle/movement.rs @@ -59,18 +59,22 @@ pub struct Plan { pub destroyed: BTreeSet, } -/// Plans one move on a board it only reads. +/// Plans one move on a board it only reads. `stepped` holds the objects that have +/// already stepped in this movement phase, in an earlier committed plan: an object +/// steps at most once per phase, so none of them can step again. pub struct Planner<'a> { level: &'a Level, dir: Dir, + stepped: &'a BTreeSet, plan: Plan, } impl<'a> Planner<'a> { - pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> { + pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet) -> Planner<'a> { Planner { level, dir, + stepped, plan: Plan::default(), } } @@ -100,11 +104,16 @@ impl<'a> Planner<'a> { /// Plan object `id` stepping one tile in the move's direction, with whatever /// the responses add. Returns whether it can. If it can't, the plan is left as /// it was before the call. An object already planned to move, or to be - /// destroyed, is out of the way: the answer is yes, and nothing is added. + /// destroyed, is out of the way: the answer is yes, and nothing is added. An + /// object that has already stepped in this movement phase can't step again: the + /// answer is no. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } + if self.stepped.contains(&id) { + return false; + } let level = self.level; let o = level.board().get(id); let (dx, dy) = self.dir.delta(); diff --git a/course/09-turn-pipeline/reference/ts/README.md b/course/09-turn-pipeline/reference/ts/README.md index f64c507..bc21500 100644 --- a/course/09-turn-pipeline/reference/ts/README.md +++ b/course/09-turn-pipeline/reference/ts/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 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); 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.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`. - `word.ts`: Module 9 adds `Lexicon.thingNamed` (what a conversion into a noun makes). - `movement.ts`, `parse.ts`, `resolve.ts`: unchanged. @@ -13,7 +13,7 @@ The course's own engine as of the end of Module 9. Part I's card engine is uncha - The rest of `src/engine/`: Part I, unchanged. - `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`. +- `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`. 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/content/words.json b/course/09-turn-pipeline/reference/ts/src/content/words.json index e1498be..e859dd7 100644 --- a/course/09-turn-pipeline/reference/ts/src/content/words.json +++ b/course/09-turn-pipeline/reference/ts/src/content/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "YOU", "role": "property" }, { "name": "WIN", "role": "property" }, { "name": "PUSH", "role": "property" }, 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 4ce58ef..7992331 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 @@ -141,6 +141,10 @@ export class Level { * 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 first. + * + * An object steps at most once in a movement phase. A `YOU` object that an + * earlier move already carried along (pushed, or moved by any other response) + * doesn't start a move of its own, and no later move can make it step again. */ private movement(dir: Dir): void { const [dx, dy] = delta(dir); @@ -150,10 +154,15 @@ export class Level { .filter((o) => this.hasProperty(o.id, YOU)) .sort((a, b) => ahead(b) - ahead(a) || a.id - b.id) .map((o) => o.id); + const stepped = new Set(); for (const id of you) { if (this.board.find(id) === undefined) continue; // destroyed by an earlier move - const planner = new Planner(this, dir); - if (planner.tryMove(id)) this.commit(planner.result(), dir); + if (stepped.has(id)) continue; // already moved, by an earlier move + const planner = new Planner(this, dir, stepped); + if (!planner.tryMove(id)) continue; + const plan = planner.result(); + this.commit(plan, dir); + for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved); } } @@ -169,6 +178,10 @@ export class Level { * this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s, * as new objects: the negation cancelled `X IS X`. * + * Negation is settled per object, as in `hasProperty`: an object doesn't become + * a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + * conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X). + * * Every object is decided from the board as movement left it, before any * changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. */ @@ -182,6 +195,7 @@ export class Level { if (!locked) { for (const r of aboutIt) { if (r.negated || !isConversion(r)) continue; + if (aboutIt.some((n) => n.negated && n.complement === r.complement)) continue; const kind = this.lexicon.thingNamed(r.complement); if (kind === undefined) continue; const same = (k: Kind) => Kind.format(k) === Kind.format(kind); diff --git a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts index 36fc15b..9c0e51a 100644 --- a/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts +++ b/course/09-turn-pipeline/reference/ts/src/engine/puzzle/movement.ts @@ -63,13 +63,18 @@ export interface MoveContext { responses(): readonly Response[]; } -/** Plans one move on a board it only reads. */ +/** + * Plans one move on a board it only reads. `stepped` holds the objects that have + * already stepped in this movement phase, in an earlier committed plan: an object + * steps at most once per phase, so none of them can step again. + */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; constructor( private readonly level: MoveContext, private readonly dir: Dir, + private readonly stepped: ReadonlySet, ) {} /** Does object `id` have `property`, under the rules in force before the move? */ @@ -98,10 +103,13 @@ export class Planner { * Plan object `id` stepping one tile in the move's direction, with whatever * the responses add. Returns whether it can. If it can't, the plan is left as * it was before the call. An object already planned to move, or to be - * destroyed, is out of the way: the answer is yes, and nothing is added. + * destroyed, is out of the way: the answer is yes, and nothing is added. An + * object that has already stepped in this movement phase can't step again: the + * answer is no. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; + if (this.stepped.has(id)) return false; const o = this.level.board.get(id); const [dx, dy] = delta(this.dir); const to: Pos = [o.x + dx, o.y + dy]; diff --git a/course/09-turn-pipeline/scenarios.md b/course/09-turn-pipeline/scenarios.md index b5b9bd7..106891d 100644 --- a/course/09-turn-pipeline/scenarios.md +++ b/course/09-turn-pipeline/scenarios.md @@ -90,3 +90,14 @@ Words used: the nouns `BABA`, `ROCK`, `FLAG`, `LAVA`, `KEY`, `SKULL`, `WATER`; t - `posOf(baba)` is `null`: Baba stepped onto the flag, which is `DEFEAT`. - `won()` is false: `DEFEAT` runs before the win check, so no `YOU` object is left to win. - `lost()` is true, and `posOf(flag)` is (1,3). + +### test_09_negated_conversion_beats_group + +This test needs the Module 7 Expansion Drop: the word `ALL` in the lexicon. + +- Setup: `newBaba` with an empty level 4 wide and 5 high. Place `BABA (0,0) IS (1,0) YOU (2,0)`, `ALL (0,1) IS (1,1) ROCK (2,1)` and `BABA (0,2) IS (1,2) NOT (2,2) ROCK (3,2)`. Place an object `baba` at (0,4) and an object `key` at (3,4). +- Action: `step("right")`. +- Assertions: + - `kindsAt(3,4)` is `["rock"]`: the key is named by `ALL`, so `ALL IS ROCK` converted it. + - `posOf(baba)` is (1,4) and `kindsAt(1,4)` is `["baba"]`: Baba is named by both `ALL` and `BABA`, and `BABA IS NOT ROCK` keeps it from becoming a rock. A negated conversion is settled per object, as `hasProperty` settles a negated property. + - `activeRules()` contains both `ALL IS ROCK` and `BABA IS NOT ROCK`: the two rules have different subjects, so neither cancels the other as written. diff --git a/course/09-turn-pipeline/tests/rust/test_09.rs b/course/09-turn-pipeline/tests/rust/test_09.rs index dbc2bc9..16badff 100644 --- a/course/09-turn-pipeline/tests/rust/test_09.rs +++ b/course/09-turn-pipeline/tests/rust/test_09.rs @@ -182,3 +182,25 @@ fn test_09_defeat_beats_win() { assert!(b.lost()); assert_eq!(b.pos_of(flag), Some((1, 3))); } + +#[test] +fn test_09_negated_conversion_beats_group() { + let mut b = new_baba(empty_level(4, 5)); + write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]); + write(&mut b, &[("ALL", 0, 1), ("IS", 1, 1), ("ROCK", 2, 1)]); + write( + &mut b, + &[("BABA", 0, 2), ("IS", 1, 2), ("NOT", 2, 2), ("ROCK", 3, 2)], + ); + let baba = b.place("baba", 0, 4); + b.place("key", 3, 4); + + b.step(Dir::Right); + + assert_eq!(b.kinds_at(3, 4), vec!["rock"]); + assert_eq!(b.pos_of(baba), Some((1, 4))); + assert_eq!(b.kinds_at(1, 4), vec!["baba"]); + let active = b.active_rules(); + assert!(active.contains(&"ALL IS ROCK".to_string())); + assert!(active.contains(&"BABA IS NOT ROCK".to_string())); +} 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 42c94f5..6c82d86 100644 --- a/course/09-turn-pipeline/tests/ts/test_09.test.ts +++ b/course/09-turn-pipeline/tests/ts/test_09.test.ts @@ -248,3 +248,33 @@ test("test_09_defeat_beats_win", () => { expect(b.lost()).toBe(true); expect(b.posOf(flag)).toEqual([1, 3]); }); + +test("test_09_negated_conversion_beats_group", () => { + const b = newBaba(emptyLevel(4, 5)); + write(b, [ + ["BABA", 0, 0], + ["IS", 1, 0], + ["YOU", 2, 0], + ]); + write(b, [ + ["ALL", 0, 1], + ["IS", 1, 1], + ["ROCK", 2, 1], + ]); + write(b, [ + ["BABA", 0, 2], + ["IS", 1, 2], + ["NOT", 2, 2], + ["ROCK", 3, 2], + ]); + const baba = b.place("baba", 0, 4); + b.place("key", 3, 4); + + b.step("right"); + + expect(b.kindsAt(3, 4)).toEqual(["rock"]); + expect(b.posOf(baba)).toEqual([1, 4]); + expect(b.kindsAt(1, 4)).toEqual(["baba"]); + expect(b.activeRules()).toContain("ALL IS ROCK"); + expect(b.activeRules()).toContain("BABA IS NOT ROCK"); +}); diff --git a/course/10-spellgrid/expansion/solution/words.json b/course/10-spellgrid/expansion/solution/words.json index ff78552..6774fee 100644 --- a/course/10-spellgrid/expansion/solution/words.json +++ b/course/10-spellgrid/expansion/solution/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "KEG", "role": "noun" }, { "name": "BRAMBLE", "role": "noun" }, { "name": "HEDGE", "role": "noun" }, diff --git a/course/10-spellgrid/reference/rust/src/content/words.json b/course/10-spellgrid/reference/rust/src/content/words.json index bb7c86c..f53f69c 100644 --- a/course/10-spellgrid/reference/rust/src/content/words.json +++ b/course/10-spellgrid/reference/rust/src/content/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "KEG", "role": "noun" }, { "name": "BRAMBLE", "role": "noun" }, { "name": "HEDGE", "role": "noun" }, 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 a046928..da06746 100644 --- a/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/level.rs @@ -13,6 +13,8 @@ //! removes them. SpellGrid passes one that sends minions to Part I's Death Creation //! Step instead. +use std::collections::BTreeSet; + use super::grid::{Board, Kind, LevelDef, ObjectId}; use super::movement::{Dir, Plan, Planner, Response}; use super::parse::{parse, Rule}; @@ -178,6 +180,10 @@ impl Level { /// (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 /// first. + /// + /// An object steps at most once in a movement phase. A `YOU` object that an + /// earlier move already carried along (pushed, or moved by any other response) + /// doesn't start a move of its own, and no later move can make it step again. fn movement(&mut self, dir: Dir, destroy: &mut Destroy) { let (dx, dy) = dir.delta(); let mut you: Vec<_> = self @@ -187,14 +193,19 @@ impl Level { .map(|o| (-(o.x * dx + o.y * dy), o.id)) .collect(); you.sort(); + let mut stepped = BTreeSet::new(); for (_, id) in you { if self.board.find(id).is_none() { continue; // destroyed by an earlier move } - let mut planner = Planner::new(self, dir); + if stepped.contains(&id) { + continue; // already moved, by an earlier move + } + let mut planner = Planner::new(self, dir, &stepped); if planner.try_move(id) { let plan = planner.into_plan(); self.commit(&plan, dir, destroy); + stepped.extend(plan.moving.difference(&plan.destroyed)); } } } @@ -219,6 +230,10 @@ impl Level { /// object blocks this. An object named by `X IS NOT X` is deleted, and still /// makes its `Y`s, as new objects: the negation cancelled `X IS X`. /// + /// Negation is settled per object, as in `has_property`: an object doesn't + /// become a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + /// conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X). + /// /// Every object is decided from the board as movement left it, before any /// changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. fn convert(&mut self, destroy: &mut Destroy) { @@ -234,6 +249,10 @@ impl Level { let mut into: Vec = vec![]; if !locked { for r in about_it.iter().filter(|r| !r.negated && r.is_conversion()) { + let negated = |n: &&Rule| n.negated && n.complement == r.complement; + if about_it.iter().any(negated) { + continue; + } match self.lexicon.thing_named(&r.complement) { Some(kind) if kind != o.kind && !into.contains(&kind) => into.push(kind), _ => {} diff --git a/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs index 4d0a381..fb71ef5 100644 --- a/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs +++ b/course/10-spellgrid/reference/rust/src/engine/puzzle/movement.rs @@ -59,18 +59,22 @@ pub struct Plan { pub destroyed: BTreeSet, } -/// Plans one move on a board it only reads. +/// Plans one move on a board it only reads. `stepped` holds the objects that have +/// already stepped in this movement phase, in an earlier committed plan: an object +/// steps at most once per phase, so none of them can step again. pub struct Planner<'a> { level: &'a Level, dir: Dir, + stepped: &'a BTreeSet, plan: Plan, } impl<'a> Planner<'a> { - pub fn new(level: &'a Level, dir: Dir) -> Planner<'a> { + pub fn new(level: &'a Level, dir: Dir, stepped: &'a BTreeSet) -> Planner<'a> { Planner { level, dir, + stepped, plan: Plan::default(), } } @@ -100,11 +104,16 @@ impl<'a> Planner<'a> { /// Plan object `id` stepping one tile in the move's direction, with whatever /// the responses add. Returns whether it can. If it can't, the plan is left as /// it was before the call. An object already planned to move, or to be - /// destroyed, is out of the way: the answer is yes, and nothing is added. + /// destroyed, is out of the way: the answer is yes, and nothing is added. An + /// object that has already stepped in this movement phase can't step again: the + /// answer is no. pub fn try_move(&mut self, id: ObjectId) -> bool { if self.plan.moving.contains(&id) || self.plan.destroyed.contains(&id) { return true; } + if self.stepped.contains(&id) { + return false; + } let level = self.level; let o = level.board().get(id); let (dx, dy) = self.dir.delta(); diff --git a/course/10-spellgrid/reference/ts/src/content/words.json b/course/10-spellgrid/reference/ts/src/content/words.json index bb7c86c..f53f69c 100644 --- a/course/10-spellgrid/reference/ts/src/content/words.json +++ b/course/10-spellgrid/reference/ts/src/content/words.json @@ -8,6 +8,7 @@ { "name": "DOOR", "role": "noun" }, { "name": "SKULL", "role": "noun" }, { "name": "WATER", "role": "noun" }, + { "name": "ALL", "role": "noun", "selects": "things" }, { "name": "KEG", "role": "noun" }, { "name": "BRAMBLE", "role": "noun" }, { "name": "HEDGE", "role": "noun" }, 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 520e13c..d1ed406 100644 --- a/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/level.ts @@ -156,6 +156,10 @@ export class Level { * 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 first. + * + * An object steps at most once in a movement phase. A `YOU` object that an + * earlier move already carried along (pushed, or moved by any other response) + * doesn't start a move of its own, and no later move can make it step again. */ private movement(dir: Dir, destroy: Destroy): void { const [dx, dy] = delta(dir); @@ -165,10 +169,15 @@ export class Level { .filter((o) => this.hasProperty(o.id, YOU)) .sort((a, b) => ahead(b) - ahead(a) || a.id - b.id) .map((o) => o.id); + const stepped = new Set(); for (const id of you) { if (this.board.find(id) === undefined) continue; // destroyed by an earlier move - const planner = new Planner(this, dir); - if (planner.tryMove(id)) this.commit(planner.result(), dir, destroy); + if (stepped.has(id)) continue; // already moved, by an earlier move + const planner = new Planner(this, dir, stepped); + if (!planner.tryMove(id)) continue; + const plan = planner.result(); + this.commit(plan, dir, destroy); + for (const moved of plan.moving) if (!plan.destroyed.has(moved)) stepped.add(moved); } } @@ -187,6 +196,10 @@ export class Level { * this. An object named by `X IS NOT X` is deleted, and still makes its `Y`s, * as new objects: the negation cancelled `X IS X`. * + * Negation is settled per object, as in `hasProperty`: an object doesn't become + * a `Y` if any active `... IS NOT Y` names it, whatever noun gave it the + * conversion (`ALL IS Y` with `X IS NOT Y` leaves every X an X). + * * Every object is decided from the board as movement left it, before any * changes, so `X IS Y` and `Y IS X` swap the Xs and the Ys. */ @@ -200,6 +213,7 @@ export class Level { if (!locked) { for (const r of aboutIt) { if (r.negated || !isConversion(r)) continue; + if (aboutIt.some((n) => n.negated && n.complement === r.complement)) continue; const kind = this.lexicon.thingNamed(r.complement); if (kind === undefined) continue; const same = (k: Kind) => Kind.format(k) === Kind.format(kind); diff --git a/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts index 36fc15b..9c0e51a 100644 --- a/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts +++ b/course/10-spellgrid/reference/ts/src/engine/puzzle/movement.ts @@ -63,13 +63,18 @@ export interface MoveContext { responses(): readonly Response[]; } -/** Plans one move on a board it only reads. */ +/** + * Plans one move on a board it only reads. `stepped` holds the objects that have + * already stepped in this movement phase, in an earlier committed plan: an object + * steps at most once per phase, so none of them can step again. + */ export class Planner { private plan: Plan = { moving: new Set(), destroyed: new Set() }; constructor( private readonly level: MoveContext, private readonly dir: Dir, + private readonly stepped: ReadonlySet, ) {} /** Does object `id` have `property`, under the rules in force before the move? */ @@ -98,10 +103,13 @@ export class Planner { * Plan object `id` stepping one tile in the move's direction, with whatever * the responses add. Returns whether it can. If it can't, the plan is left as * it was before the call. An object already planned to move, or to be - * destroyed, is out of the way: the answer is yes, and nothing is added. + * destroyed, is out of the way: the answer is yes, and nothing is added. An + * object that has already stepped in this movement phase can't step again: the + * answer is no. */ tryMove(id: ObjectId): boolean { if (this.plan.moving.has(id) || this.plan.destroyed.has(id)) return true; + if (this.stepped.has(id)) return false; const o = this.level.board.get(id); const [dx, dy] = delta(this.dir); const to: Pos = [o.x + dx, o.y + dy];