Merge branch 'worktree-agent-ab151ac84f067999f' into revise/edge-cases-and-sources

This commit is contained in:
Lucas Winther committed 2026-11-11 09:19:32 +01:00
commit d5d742450c
28 files changed
+293 -32

No files matched your search

@@ -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.
@@ -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)));
}
@@ -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]);
});
@@ -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.
@@ -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();
@@ -59,18 +59,22 @@ pub struct Plan {
pub destroyed: BTreeSet<ObjectId>,
}
/// 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<ObjectId>,
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<ObjectId>) -> 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();
+2 -2
View File
@@ -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.
@@ -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<ObjectId>();
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();
}
@@ -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<ObjectId>,
) {}
/** 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];