feat: module 08 push chains

Movement planned in full, then committed: a Planner builds a plan
(moving and destroyed ids) by running content's movement responses
(OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches.
Edge of level blocks; YOU objects move furthest-ahead first; rules are
reparsed once after movement. Harness: move/move_dir(Dir), posOf,
kindsAt. Expansion Drop: PULL as one word plus one content response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 04:05:42 +01:00
1 parent d993457772
commit bd80cefbef
64 files changed
+6933

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
// Module 8: Push chains. Each test matches a heading in scenarios.md.
use engine::harness::*;
/// An empty level `width` wide and `height` high.
fn empty_level(width: i32, height: i32) -> LevelDef {
LevelDef {
width,
height,
..Default::default()
}
}
/// Place one text object per word: `(word, x, y)`.
fn write(b: &mut Baba, words: &[(&str, i32, i32)]) {
for &(word, x, y) in words {
b.place(&format!("text:{word}"), x, y);
}
}
#[test]
fn test_08_chain_moves_together() {
let mut b = new_baba(empty_level(6, 4));
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
write(&mut b, &[("ROCK", 0, 1), ("IS", 1, 1), ("PUSH", 2, 1)]);
let baba = b.place("baba", 0, 3);
let rocks = [
b.place("rock", 1, 3),
b.place("rock", 2, 3),
b.place("rock", 3, 3),
];
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(baba), Some((1, 3)));
assert_eq!(b.pos_of(rocks[0]), Some((2, 3)));
assert_eq!(b.pos_of(rocks[1]), Some((3, 3)));
assert_eq!(b.pos_of(rocks[2]), Some((4, 3)));
assert_eq!(b.kinds_at(0, 3), Vec::<String>::new());
assert_eq!(b.kinds_at(4, 3), vec!["rock"]);
}
#[test]
fn test_08_chain_into_stop_nothing_moves() {
let mut b = new_baba(empty_level(6, 5));
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
write(&mut b, &[("ROCK", 0, 1), ("IS", 1, 1), ("PUSH", 2, 1)]);
write(&mut b, &[("WALL", 0, 2), ("IS", 1, 2), ("STOP", 2, 2)]);
let baba = b.place("baba", 0, 4);
let rocks = [
b.place("rock", 1, 4),
b.place("rock", 2, 4),
b.place("rock", 3, 4),
];
let wall = b.place("wall", 4, 4);
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(baba), Some((0, 4)));
assert_eq!(b.pos_of(rocks[0]), Some((1, 4)));
assert_eq!(b.pos_of(rocks[1]), Some((2, 4)));
assert_eq!(b.pos_of(rocks[2]), Some((3, 4)));
assert_eq!(b.pos_of(wall), Some((4, 4)));
}
#[test]
fn test_08_open_shut_during_push() {
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),
("PUSH", 2, 1),
("AND", 3, 1),
("OPEN", 4, 1),
],
);
write(
&mut b,
&[
("DOOR", 0, 2),
("IS", 1, 2),
("STOP", 2, 2),
("AND", 3, 2),
("SHUT", 4, 2),
],
);
let baba = b.place("baba", 0, 4);
let key = b.place("key", 1, 4);
let door = b.place("door", 2, 4);
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(key), None);
assert_eq!(b.pos_of(door), None);
// Inferred, not stated by a source: Baba lands on the key's former tile.
assert_eq!(b.pos_of(baba), Some((1, 4)));
assert_eq!(b.kinds_at(2, 4), Vec::<String>::new());
assert_eq!(b.kinds_at(1, 4), vec!["baba"]);
}
#[test]
fn test_08_text_pushed() {
let mut b = new_baba(empty_level(4, 5));
write(&mut b, &[("BABA", 0, 0), ("IS", 1, 0), ("YOU", 2, 0)]);
let baba = b.place("baba", 0, 2);
let rock_text = b.place("text:ROCK", 1, 2);
write(&mut b, &[("IS", 2, 3), ("PUSH", 2, 4)]);
assert_eq!(b.rules(), vec!["BABA IS YOU", "TEXT IS PUSH"]);
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(baba), Some((1, 2)));
assert_eq!(b.pos_of(rock_text), Some((2, 2)));
assert_eq!(
b.rules(),
vec!["BABA IS YOU", "ROCK IS PUSH", "TEXT IS PUSH"]
);
}
#[test]
fn test_08_level_edge_is_stop() {
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", 1, 2);
let flag_text = b.place("text:FLAG", 2, 2);
b.move_dir(Dir::Right);
assert_eq!(b.pos_of(baba), Some((1, 2)));
assert_eq!(b.pos_of(flag_text), Some((2, 2)));
b.move_dir(Dir::Down);
assert_eq!(b.pos_of(baba), Some((1, 2)));
assert_eq!(b.pos_of(flag_text), Some((2, 2)));
}
@@ -0,0 +1,145 @@
// Module 8: Push chains. Each test matches a heading in scenarios.md.
import { expect, test } from "vitest";
import { newBaba, type Baba } from "../src/harness";
/** An empty level `width` wide and `height` high. */
const emptyLevel = (width: number, height: number) => ({ width, height, objects: [] });
/** Place one text object per word: `[word, x, y]`. */
function write(b: Baba, words: [string, number, number][]): void {
for (const [word, x, y] of words) b.place(`text:${word}`, x, y);
}
test("test_08_chain_moves_together", () => {
const b = newBaba(emptyLevel(6, 4));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
write(b, [
["ROCK", 0, 1],
["IS", 1, 1],
["PUSH", 2, 1],
]);
const baba = b.place("baba", 0, 3);
const rocks = [b.place("rock", 1, 3), b.place("rock", 2, 3), b.place("rock", 3, 3)];
b.move("right");
expect(b.posOf(baba)).toEqual([1, 3]);
expect(b.posOf(rocks[0])).toEqual([2, 3]);
expect(b.posOf(rocks[1])).toEqual([3, 3]);
expect(b.posOf(rocks[2])).toEqual([4, 3]);
expect(b.kindsAt(0, 3)).toEqual([]);
expect(b.kindsAt(4, 3)).toEqual(["rock"]);
});
test("test_08_chain_into_stop_nothing_moves", () => {
const b = newBaba(emptyLevel(6, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
write(b, [
["ROCK", 0, 1],
["IS", 1, 1],
["PUSH", 2, 1],
]);
write(b, [
["WALL", 0, 2],
["IS", 1, 2],
["STOP", 2, 2],
]);
const baba = b.place("baba", 0, 4);
const rocks = [b.place("rock", 1, 4), b.place("rock", 2, 4), b.place("rock", 3, 4)];
const wall = b.place("wall", 4, 4);
b.move("right");
expect(b.posOf(baba)).toEqual([0, 4]);
expect(b.posOf(rocks[0])).toEqual([1, 4]);
expect(b.posOf(rocks[1])).toEqual([2, 4]);
expect(b.posOf(rocks[2])).toEqual([3, 4]);
expect(b.posOf(wall)).toEqual([4, 4]);
});
test("test_08_open_shut_during_push", () => {
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],
["PUSH", 2, 1],
["AND", 3, 1],
["OPEN", 4, 1],
]);
write(b, [
["DOOR", 0, 2],
["IS", 1, 2],
["STOP", 2, 2],
["AND", 3, 2],
["SHUT", 4, 2],
]);
const baba = b.place("baba", 0, 4);
const key = b.place("key", 1, 4);
const door = b.place("door", 2, 4);
b.move("right");
expect(b.posOf(key)).toBeNull();
expect(b.posOf(door)).toBeNull();
// Inferred, not stated by a source: Baba lands on the key's former tile.
expect(b.posOf(baba)).toEqual([1, 4]);
expect(b.kindsAt(2, 4)).toEqual([]);
expect(b.kindsAt(1, 4)).toEqual(["baba"]);
});
test("test_08_text_pushed", () => {
const b = newBaba(emptyLevel(4, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
const baba = b.place("baba", 0, 2);
const rockText = b.place("text:ROCK", 1, 2);
write(b, [
["IS", 2, 3],
["PUSH", 2, 4],
]);
expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH"]);
b.move("right");
expect(b.posOf(baba)).toEqual([1, 2]);
expect(b.posOf(rockText)).toEqual([2, 2]);
expect(b.rules()).toEqual(["BABA IS YOU", "ROCK IS PUSH", "TEXT IS PUSH"]);
});
test("test_08_level_edge_is_stop", () => {
const b = newBaba(emptyLevel(3, 3));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["YOU", 2, 0],
]);
const baba = b.place("baba", 1, 2);
const flagText = b.place("text:FLAG", 2, 2);
b.move("right");
expect(b.posOf(baba)).toEqual([1, 2]);
expect(b.posOf(flagText)).toEqual([2, 2]);
b.move("down");
expect(b.posOf(baba)).toEqual([1, 2]);
expect(b.posOf(flagText)).toEqual([2, 2]);
});