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>
526 lines
12 KiB
TypeScript
526 lines
12 KiB
TypeScript
// Module 9: The turn pipeline. 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_09_lava_saved_by_reparse", () => {
|
|
const b = newBaba(emptyLevel(6, 5));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["LAVA", 0, 1],
|
|
["IS", 1, 1],
|
|
["HOT", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["BABA", 4, 2],
|
|
["IS", 4, 3],
|
|
]);
|
|
const meltText = b.place("text:MELT", 4, 4);
|
|
const lava = b.place("lava", 4, 4);
|
|
const baba = b.place("baba", 3, 4);
|
|
|
|
expect(b.hasProperty(baba, "MELT")).toBe(true);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(baba)).toEqual([4, 4]);
|
|
expect(b.posOf(meltText)).toEqual([5, 4]);
|
|
// Inferred from the order of operations, not stated by a source: Baba survives.
|
|
expect(b.posOf(lava)).toEqual([4, 4]);
|
|
expect(b.kindsAt(4, 4)).toEqual(["baba", "lava"]);
|
|
expect(b.rules()).not.toContain("BABA IS MELT");
|
|
expect(b.lost()).toBe(false);
|
|
});
|
|
|
|
test("test_09_mid_turn_conversion", () => {
|
|
const b = newBaba(emptyLevel(5, 5));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 2],
|
|
["IS", 1, 2],
|
|
]);
|
|
const rockText = b.place("text:ROCK", 2, 3);
|
|
const pusher = b.place("baba", 2, 4);
|
|
const other = b.place("baba", 4, 4);
|
|
|
|
b.step("up");
|
|
|
|
expect(b.posOf(rockText)).toEqual([2, 2]);
|
|
expect(b.rules()).toContain("BABA IS ROCK");
|
|
expect(b.posOf(pusher)).toEqual([2, 3]);
|
|
expect(b.posOf(other)).toEqual([4, 3]);
|
|
expect(b.kindsAt(2, 3)).toEqual(["rock"]);
|
|
expect(b.kindsAt(4, 3)).toEqual(["rock"]);
|
|
expect(b.kindsAt(2, 4)).toEqual([]);
|
|
expect(b.kindsAt(4, 4)).toEqual([]);
|
|
});
|
|
|
|
test("test_09_identity_lock_blocks_conversion", () => {
|
|
const b = newBaba(emptyLevel(5, 5));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 1],
|
|
["IS", 1, 1],
|
|
["BABA", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 2],
|
|
["IS", 1, 2],
|
|
["ROCK", 2, 2],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 3],
|
|
["IS", 1, 3],
|
|
["ROCK", 2, 3],
|
|
]);
|
|
const baba = b.place("baba", 0, 4);
|
|
const key = b.place("key", 3, 4);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(baba)).toEqual([1, 4]);
|
|
expect(b.kindsAt(1, 4)).toEqual(["baba"]);
|
|
expect(b.kindsAt(3, 4)).toEqual(["rock"]);
|
|
expect(b.posOf(key)).toEqual([3, 4]);
|
|
const active = b.activeRules();
|
|
expect(active).not.toContain("BABA IS ROCK");
|
|
expect(active).toContain("BABA IS BABA");
|
|
expect(active).toContain("KEY IS ROCK");
|
|
});
|
|
|
|
test("test_09_self_negation_deletes", () => {
|
|
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],
|
|
["NOT", 2, 1],
|
|
["KEY", 3, 1],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 2],
|
|
["IS", 1, 2],
|
|
["ROCK", 2, 2],
|
|
]);
|
|
const baba = b.place("baba", 0, 4);
|
|
const key = b.place("key", 3, 4);
|
|
|
|
expect(b.posOf(key)).toEqual([3, 4]);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(key)).toBeNull();
|
|
expect(b.kindsAt(3, 4)).toEqual(["rock"]);
|
|
expect(b.posOf(baba)).toEqual([1, 4]);
|
|
});
|
|
|
|
test("test_09_float_separates_hazards", () => {
|
|
const b = newBaba(emptyLevel(4, 6));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 1],
|
|
["IS", 1, 1],
|
|
["YOU", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["SKULL", 0, 2],
|
|
["IS", 1, 2],
|
|
["DEFEAT", 2, 2],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 3],
|
|
["IS", 1, 3],
|
|
["FLOAT", 2, 3],
|
|
]);
|
|
const baba = b.place("baba", 0, 4);
|
|
b.place("skull", 1, 4);
|
|
const key = b.place("key", 0, 5);
|
|
b.place("skull", 1, 5);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(baba)).toEqual([1, 4]);
|
|
expect(b.kindsAt(1, 4)).toEqual(["baba", "skull"]);
|
|
expect(b.posOf(key)).toBeNull();
|
|
expect(b.kindsAt(1, 5)).toEqual(["skull"]);
|
|
expect(b.lost()).toBe(false);
|
|
});
|
|
|
|
test("test_09_sink_destroys_both", () => {
|
|
const b = newBaba(emptyLevel(5, 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, [
|
|
["WATER", 0, 2],
|
|
["IS", 1, 2],
|
|
["SINK", 2, 2],
|
|
]);
|
|
const baba = b.place("baba", 0, 4);
|
|
const rock = b.place("rock", 1, 4);
|
|
const water = b.place("water", 2, 4);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(rock)).toBeNull();
|
|
expect(b.posOf(water)).toBeNull();
|
|
expect(b.kindsAt(2, 4)).toEqual([]);
|
|
expect(b.posOf(baba)).toEqual([1, 4]);
|
|
});
|
|
|
|
test("test_09_you_and_win", () => {
|
|
const b = newBaba(emptyLevel(4, 4));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 1],
|
|
["IS", 1, 1],
|
|
["WIN", 2, 1],
|
|
]);
|
|
b.place("baba", 0, 3);
|
|
|
|
expect(b.won()).toBe(false);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.won()).toBe(true);
|
|
expect(b.lost()).toBe(false);
|
|
});
|
|
|
|
test("test_09_defeat_beats_win", () => {
|
|
const b = newBaba(emptyLevel(5, 4));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["FLAG", 0, 1],
|
|
["IS", 1, 1],
|
|
["WIN", 2, 1],
|
|
["AND", 3, 1],
|
|
["DEFEAT", 4, 1],
|
|
]);
|
|
const baba = b.place("baba", 0, 3);
|
|
const flag = b.place("flag", 1, 3);
|
|
|
|
b.step("right");
|
|
|
|
expect(b.posOf(baba)).toBeNull();
|
|
expect(b.won()).toBe(false);
|
|
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");
|
|
});
|
|
|
|
test("test_09_conversion_reparses_before_hazards", () => {
|
|
const b = newBaba(emptyLevel(5, 6));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["LAVA", 0, 1],
|
|
["IS", 1, 1],
|
|
["HOT", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["BABA", 0, 2],
|
|
["IS", 1, 2],
|
|
["MELT", 2, 2],
|
|
]);
|
|
write(b, [
|
|
["TEXT", 0, 3],
|
|
["IS", 1, 3],
|
|
["ROCK", 2, 3],
|
|
]);
|
|
const baba = b.place("baba", 0, 5);
|
|
b.place("lava", 1, 5);
|
|
|
|
b.step("right");
|
|
|
|
// Conversion turned every word into a rock, so no rule makes Baba MELT when the hazards run.
|
|
expect(b.kindsAt(0, 0)).toEqual(["rock"]);
|
|
expect(b.rules()).toEqual(["TEXT IS PUSH"]);
|
|
expect(b.posOf(baba)).toEqual([1, 5]);
|
|
expect(b.kindsAt(1, 5)).toEqual(["baba", "lava"]);
|
|
expect(b.lost()).toBe(true);
|
|
});
|
|
|
|
test("test_09_conversion_before_hazards", () => {
|
|
const b = newBaba(emptyLevel(5, 6));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["LAVA", 0, 1],
|
|
["IS", 1, 1],
|
|
["HOT", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 2],
|
|
["IS", 1, 2],
|
|
["MELT", 2, 2],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 3],
|
|
["IS", 1, 3],
|
|
["ROCK", 2, 3],
|
|
]);
|
|
b.place("baba", 0, 5);
|
|
b.place("lava", 3, 5);
|
|
const key = b.place("key", 3, 5);
|
|
|
|
b.step("right");
|
|
|
|
// The key became a rock before the hazards ran, and a rock is not MELT.
|
|
expect(b.posOf(key)).toEqual([3, 5]);
|
|
expect(b.kindsAt(3, 5)).toEqual(["lava", "rock"]);
|
|
});
|
|
|
|
test("test_09_last_parse_matches_board", () => {
|
|
const b = newBaba(emptyLevel(6, 6));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["FLAG", 0, 2],
|
|
["IS", 1, 2],
|
|
]);
|
|
const winText = b.place("text:WIN", 2, 2);
|
|
const water = b.place("water", 2, 2);
|
|
write(b, [
|
|
["WATER", 3, 4],
|
|
["IS", 4, 4],
|
|
["SINK", 5, 3],
|
|
]);
|
|
b.place("baba", 5, 2);
|
|
|
|
expect(b.rules()).toContain("FLAG IS WIN");
|
|
|
|
b.step("down");
|
|
|
|
// The push completed WATER IS SINK; the water then sank the word WIN it lay under.
|
|
expect(b.posOf(winText)).toBeNull();
|
|
expect(b.posOf(water)).toBeNull();
|
|
expect(b.rules()).toEqual(["BABA IS YOU", "TEXT IS PUSH", "WATER IS SINK"]);
|
|
});
|
|
|
|
test("test_09_hazard_order_melt_before_defeat", () => {
|
|
const b = newBaba(emptyLevel(5, 5));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["LAVA", 0, 1],
|
|
["IS", 1, 1],
|
|
["HOT", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["SKULL", 0, 2],
|
|
["IS", 1, 2],
|
|
["DEFEAT", 2, 2],
|
|
["AND", 3, 2],
|
|
["MELT", 4, 2],
|
|
]);
|
|
const baba = b.place("baba", 0, 4);
|
|
b.place("lava", 1, 4);
|
|
const skull = b.place("skull", 1, 4);
|
|
|
|
b.step("right");
|
|
|
|
// Inferred from the order of block(): HOT/MELT removes the skull before DEFEAT is checked.
|
|
expect(b.posOf(skull)).toBeNull();
|
|
expect(b.posOf(baba)).toEqual([1, 4]);
|
|
expect(b.lost()).toBe(false);
|
|
});
|
|
|
|
test("test_09_hazard_order_sink_before_melt", () => {
|
|
const b = newBaba(emptyLevel(5, 5));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
write(b, [
|
|
["WATER", 0, 1],
|
|
["IS", 1, 1],
|
|
["SINK", 2, 1],
|
|
]);
|
|
write(b, [
|
|
["KEY", 0, 2],
|
|
["IS", 1, 2],
|
|
["HOT", 2, 2],
|
|
["AND", 3, 2],
|
|
["MELT", 4, 2],
|
|
]);
|
|
b.place("baba", 0, 4);
|
|
const water = b.place("water", 4, 4);
|
|
const key = b.place("key", 4, 4);
|
|
|
|
b.step("right");
|
|
|
|
// Inferred from the order of block(): SINK runs while the key is still there to sink with.
|
|
expect(b.posOf(key)).toBeNull();
|
|
expect(b.posOf(water)).toBeNull();
|
|
});
|
|
|
|
/** Everything the harness shows: every tile's kinds and stack, where each id up to `lastId` is, the rules, the outcome. */
|
|
function view(b: Baba, width: number, height: number, lastId: number) {
|
|
const tiles: [string[], string[]][] = [];
|
|
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) tiles.push([b.kindsAt(x, y), b.stackAt(x, y)]);
|
|
const positions = Array.from({ length: lastId }, (_, i) => b.posOf(i + 1));
|
|
return { tiles, positions, rules: b.rules(), active: b.activeRules(), won: b.won(), lost: b.lost() };
|
|
}
|
|
|
|
test("test_09_undo_restores_everything", () => {
|
|
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],
|
|
["ROCK", 2, 1],
|
|
["AND", 3, 1],
|
|
["FLAG", 4, 1],
|
|
]);
|
|
write(b, [
|
|
["ROCK", 0, 2],
|
|
["IS", 1, 2],
|
|
["MELT", 2, 2],
|
|
]);
|
|
write(b, [
|
|
["LAVA", 0, 3],
|
|
["IS", 1, 3],
|
|
["HOT", 2, 3],
|
|
]);
|
|
write(b, [
|
|
["FLAG", 0, 4],
|
|
["IS", 1, 4],
|
|
]);
|
|
const winText = b.place("text:WIN", 3, 4);
|
|
const key = b.place("key", 3, 4);
|
|
b.place("lava", 3, 4);
|
|
const baba = b.place("baba", 4, 4);
|
|
expect(baba).toBe(20);
|
|
const before = view(b, 5, 5, 20);
|
|
|
|
b.step("left");
|
|
|
|
// The turn moved Baba and the word WIN, converted the key, melted it and won.
|
|
expect(b.posOf(baba)).toEqual([3, 4]);
|
|
expect(b.posOf(winText)).toEqual([2, 4]);
|
|
expect(b.posOf(key)).toBeNull();
|
|
expect(b.stackAt(3, 4)).toEqual(["lava", "baba", "flag"]);
|
|
expect(b.posOf(21)).toEqual([3, 4]);
|
|
expect(b.rules()).toContain("FLAG IS WIN");
|
|
expect(b.won()).toBe(true);
|
|
const after = view(b, 5, 5, 21);
|
|
|
|
expect(b.undo()).toBe(true);
|
|
|
|
expect(view(b, 5, 5, 20)).toEqual(before);
|
|
|
|
b.step("left");
|
|
|
|
// The same step gives the same result, ids included: the new flag is 21 again.
|
|
expect(view(b, 5, 5, 21)).toEqual(after);
|
|
});
|
|
|
|
test("test_09_undo_walks_back_and_stops", () => {
|
|
const b = newBaba(emptyLevel(4, 3));
|
|
write(b, [
|
|
["BABA", 0, 0],
|
|
["IS", 1, 0],
|
|
["YOU", 2, 0],
|
|
]);
|
|
const baba = b.place("baba", 0, 2);
|
|
|
|
b.step("right");
|
|
b.step("right");
|
|
|
|
expect(b.posOf(baba)).toEqual([2, 2]);
|
|
expect(b.undo()).toBe(true);
|
|
expect(b.posOf(baba)).toEqual([1, 2]);
|
|
expect(b.undo()).toBe(true);
|
|
expect(b.posOf(baba)).toEqual([0, 2]);
|
|
expect(b.undo()).toBe(false);
|
|
expect(b.posOf(baba)).toEqual([0, 2]);
|
|
});
|