Files
Crystal/tests/native.test.ts
T

640 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { RomReader } from "../src/game/rom/reader";
import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import {
ScriptRun,
emptyScriptState,
runScript,
} from "../src/game/scripts/interpreter";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { VECTORS, type Direction } from "../src/game/world/types";
describe("native ROM formats", () => {
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
const reader = new RomReader(new Uint8Array(0xc000));
expect(reader.address(2, 0x4321)).toBe(0x8321);
expect(reader.address(2, 0x1234)).toBe(0x1234);
expect(() => reader.address(3, 0x4000)).toThrow();
expect(() => reader.word(0xbfff)).toThrow();
expect(() => reader.slice(-1, 1)).toThrow();
});
it("decodes all LZ commands, long runs, overlapping copies and termination", () => {
const decode = (input: number[]) => [
...decompress(new RomReader(Uint8Array.from(input)), 0),
];
expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([
1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0,
]);
expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]);
expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]);
expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]);
expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0));
expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference");
expect(() =>
decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10),
).toThrow("oversized");
expect(() => decode([0x20, 4])).toThrow();
});
it("decodes planar pixels with transparency and flips", () => {
const pixels = new Uint8ClampedArray(8 * 8 * 4);
const tile = new Uint8Array(16);
tile[0] = 0x80;
tile[1] = 0x40;
const palette = [
[0, 0, 0],
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
] as const;
drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, {
transparent: true,
flipX: true,
});
expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]);
expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]);
expect(pixels[3]).toBe(0);
});
it("aborts unknown script commands without committing earlier event writes", () => {
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
const state = emptyScriptState();
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
expect(state.events.size).toBe(0);
});
it("bounds looping scripts", () => {
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow(
"budget",
);
});
it("resumes scripts after choices and writes only to a private draft", () => {
const bytes = new Uint8Array(0x100);
// writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end
bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0);
bytes.set([0x33, 7, 0, 0x91], 0x10);
// "HI" then the player's name through text_ram, then done.
bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40);
const state = emptyScriptState();
const host = {
mapId: "1:1",
lastTalked: 0,
sceneOwner: () => undefined,
timeMask: () => 2,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
text: () => defaultTextContext("KAWA"),
};
const yes = new ScriptRun(new RomReader(bytes), 0, state, host);
expect(yes.resume()).toEqual({ kind: "yesno", pages: ["HI KAWA"] });
expect(yes.resume(1)).toBeUndefined();
expect(yes.state.events.has(7)).toBe(true);
expect(state.events.size).toBe(0);
const no = new ScriptRun(new RomReader(bytes), 0, state, host);
no.resume();
expect(no.resume(0)).toBeUndefined();
expect(no.state.events.size).toBe(0);
});
it("decodes movement data and refuses unported movement commands", () => {
const reader = new RomReader(
Uint8Array.from([
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47,
]),
);
expect(readMovement(reader, 0)).toEqual([
{ kind: "turn", direction: "left", ticks: 1 },
{ kind: "step", direction: "right", ticks: 8, animate: true },
{ kind: "step", direction: "down", ticks: 16, animate: true },
{ kind: "jump", direction: "up", ticks: 8 },
{ kind: "fix-facing", fixed: true },
{ kind: "sleep", ticks: 3 },
{ kind: "sleep", ticks: 12 },
]);
expect(() => readMovement(reader, 9)).toThrow("not ported");
});
it("reads RAM-backed names and string buffers only from native values", () => {
// text_ram wStringBuffer4, " ", text_buffer 0, done
const bytes = Uint8Array.from([
0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50,
]);
const context = {
...defaultTextContext("KAWA"),
strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" },
};
expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM");
expect(() =>
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
).toThrow("no native value");
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"native engine with local user ROM (never checked in)",
() => {
const load = () => {
const bytes = readFileSync(romPath);
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
CRYSTAL_ROM.sha1,
);
return new CrystalAssets(new Uint8Array(bytes));
};
it("decodes every supported map, tileset, event table and sprite", () => {
const assets = load();
for (const id of Object.keys(NATIVE_MAPS)) {
const map = assets.map(id),
tileset = assets.tileset(map);
expect(map.blocks.length).toBe((map.width * map.height) / 4);
expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600);
expect(assets.palettes(map, 1).length).toBe(8);
for (const warp of map.warps) {
expect(warp.x).toBeLessThan(map.width);
expect(warp.y).toBeLessThan(map.height);
if (warp.targetMap in NATIVE_MAPS)
expect(
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
).toBeDefined();
}
}
expect(assets.sprite(1)?.graphics.length).toBe(384);
expect(assets.sprite(0x60)?.graphics.length).toBe(384);
expect(assets.map("24:7").warps[0]).toEqual({
x: 7,
y: 0,
targetMap: "24:6",
targetWarp: 3,
});
});
it.skipIf(
!existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"),
)(
"matches decompressed graphics against the locally rebuilt PRET reference",
() => {
const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp";
const assets = load();
expect(assets.tileset(assets.map("24:7")).graphics).toEqual(
new Uint8Array(readFileSync(path)),
);
},
);
it("walks with the original timing: eight overworld ticks per tile", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
const ticks: number[] = [];
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
const x = world.x;
world.tick("right");
if (world.x !== x) ticks.push(tick);
}
// Steps start on ticks 1, 9, 17... and continue without a gap.
expect(ticks).toEqual([1, 9, 17, 25]);
// Two pixels per tick: the step started on tick 25, now two ticks in.
world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.x).toBe(6 * 16 + 4);
});
it("decodes ROM location names and draws connected maps beyond the edges", () => {
const assets = load();
const town = assets.map("24:4");
expect(town.location).toBe("New Bark Town");
expect(assets.map("24:5").name).toBe("Elm’s lab");
expect(assets.map("24:5").location).toBe("New Bark Town");
const west = town.connections.find((c) => c.direction === "left")!;
const route = assets.map(west.targetMap);
expect(route.location).toBe("Route 29");
expect(assets.isPorted(route.id)).toBe(false);
for (let y = 0; y < town.height; y++)
expect(assets.visibleBlock(town, -1, y)).toBe(
assets.block(route, west.x, y + west.y),
);
expect(assets.visibleBlock(town, -1, -1)).toBe(town.border);
expect(() => assets.map("24:99")).toThrow("Unknown map");
expect(() => assets.map("27:1")).toThrow("Unknown map");
});
it("plays Mom's opening scene with native choices, then walks outside", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
// The bedroom's NEWMAP callback ran the original initialization script.
expect(world.state.events.size).toBeGreaterThan(100);
walkTo(world, 7, 0);
expect(world.mapId).toBe("24:6");
expect([world.x, world.y]).toEqual([9, 1]);
const flags = world.state.flags.size;
// Monday, confirm, DST yes, confirm time, "know how to use the phone".
const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]);
expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF.");
expect(pages).toContain("CHRIS received\nPOKéGEAR.");
expect(pages).toContain("What day is it?");
expect(pages).toContain("MONDAY, is it?");
expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true);
expect(pages).toContain("Don't you just\nturn the POKéGEAR");
const save = world.save();
expect(save.scenes).toEqual({ "24:6": 1 });
expect(save.phone).toEqual([1]);
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
expect(save.clock).toEqual({ weekdayOffset: 6, dst: true });
expect(world.clock().weekday).toBe(1);
expect(world.state.flags.size).toBe(flags + 2);
expect(world.state.events.has(1735)).toBe(true);
// Stepping on the tile again does not replay the scene.
expect(world.move("up")).toBe(true);
expect(settle(world)).toEqual([]);
// Objects spawn when the map loads: the scene's Mom stays until the
// player leaves, then the daytime Mom replaces her.
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
x: 7,
y: 4,
direction: "left",
});
walkTo(world, 6, 7);
expect(world.mapId).toBe("24:6");
expect(world.move("down")).toBe(true);
settle(world);
expect(world.mapId).toBe("24:4");
const home = world.map.warps.find((warp) => warp.targetMap === "24:6")!;
walkTo(world, home.x, home.y);
expect(world.objects().map((object) => object.id)).toEqual([4, 6]);
walkTo(world, 6, 7);
const restored = new GameWorld(
world.assets,
() => new Date("2026-09-22T12:00:00"),
);
restored.restore(world.save());
expect(restored.save()).toEqual(world.save());
expect(() => world.restore({ ...world.save(), x: 127 })).toThrow(
"position",
);
expect(() =>
world.restore({ ...world.save(), scenes: { "24:4": 3 } }),
).not.toThrow();
expect(() =>
world.restore({ ...world.save(), scenes: { "1:1": 3 } }),
).toThrow("Invalid");
});
it("saves the committed state during a scene and replays it after restore", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
walkTo(world, 7, 0);
walkTo(world, 9, 3);
const before = world.save();
expect(world.move("down")).toBe(true);
expect(world.busy).toBe(true);
// A mid-scene save keeps the position before the triggering step.
expect(world.save()).toEqual(before);
const restored = new GameWorld(
world.assets,
() => new Date("2026-09-22T12:00:00"),
);
restored.restore(world.save());
expect(restored.move("down")).toBe(true);
expect(settle(restored, [1, 1, 1, 1, 1]).length).toBeGreaterThan(10);
expect(restored.save().scenes).toEqual({ "24:6": 1 });
});
it("runs New Bark Town's teacher and rival scenes with scripted movement", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
world.restore({ ...world.save(), map: "24:4", x: 5, y: 8 });
walkTo(world, 2, 8);
const events = world.save().events;
expect(world.move("left")).toBe(true);
const pages = settle(world);
expect(pages[0]).toBe("Wait, CHRIS!");
expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!");
// The teacher runs over and leads the player back east.
expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]);
const teacher = world.objects().find((object) => object.id === 2)!;
expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]);
expect(world.save().events).toEqual(events);
walkTo(world, 2, 2);
expect(world.move("right")).toBe(false);
world.interact();
const rival = settle(world);
expect(rival).toContain("…What are you\nstaring at?");
// Pushed one step and shoved two more with a jump.
expect([world.x, world.y, world.direction]).toEqual([2, 5, "up"]);
const silver = world.objects().find((object) => object.id === 4)!;
expect([silver.x, silver.y, silver.direction]).toEqual([3, 2, "right"]);
});
it("runs Elm's introduction, the lab's exit guard and a declined Poké Ball", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
world.restore({
...world.save(),
map: "24:4",
x: 6,
y: 4,
scenes: { "24:6": 1 },
});
const door = world.map.warps.find((warp) => warp.targetMap === "24:5")!;
walkTo(world, door.x, door.y + 1);
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe("24:5");
// The OBJECTS callback moved Elm for the introduction.
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
x: 3,
y: 4,
});
const pages = settle(world, [0, 1]);
expect(pages[0]).toBe("ELM: CHRIS!\nThere you are!");
expect(pages).toContain("But… Please, I\nneed your help!");
expect(pages.at(-1)).toBe("Go on. Pick one!");
expect(world.save().scenes).toMatchObject({ "24:5": 1 });
// The warp places the player on the mat at y=11; the ROM walks up seven tiles.
expect([world.x, world.y, world.direction]).toEqual([4, 4, "right"]);
// Elm stops the player from leaving without a Pokémon.
walkTo(world, 4, 5);
expect(world.move("down")).toBe(true);
expect(settle(world).length).toBeGreaterThan(0);
expect([world.x, world.y]).toEqual([4, 5]);
walkTo(world, 6, 4);
world.move("up");
const before = world.save();
world.interact();
// Declining a Poké Ball commits nothing.
expect(settle(world, [0])).toEqual([
"",
"ELM: You'll take\nCYNDAQUIL, the\nfire POKéMON?",
"ELM: Think it over\ncarefully.",
"Your partner is\nimportant.",
]);
expect(world.save()).toEqual(before);
});
it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load();
const route = assets.map("24:3");
expect(assets.isPorted(route.id)).toBe(false);
// Enable only in this fixture: production still refuses Route 29's unported scripts.
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => id === route.id || isPorted(id);
const world = new GameWorld(assets);
const checked = new Set<number>();
const directions: Record<number, Direction> = {
0xa0: "right",
0xa1: "left",
0xa3: "down",
0xa4: "down",
0xa5: "down",
};
for (let y = 1; y < route.height - 2; y++) {
for (let x = 2; x < route.width - 2; x++) {
const collision = assets.collision(route, x, y),
direction = directions[collision];
if (!direction || checked.has(collision)) continue;
const [dx, dy] = VECTORS[direction];
if (
assets.permission(assets.collision(route, x + dx, y + dy)) === 0 ||
assets.permission(
assets.collision(route, x + dx * 2, y + dy * 2),
) !== 0 ||
route.objects.some((o) => o.x === x + dx * 2 && o.y === y + dy * 2)
)
continue;
world.restore({
...world.save(),
map: route.id,
x,
y,
direction,
objects: [],
});
world.tick(direction);
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
expect(
world.poses().find((pose) => pose.id === 0)?.lift,
).toBeLessThan(0);
settle(world);
expect(world.notice).toBe("");
checked.add(collision);
}
}
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
});
it("refuses connections into unported maps and imports version 1 saves", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const town = world.assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) =>
world.assets.permission(world.assets.collision(town, 0, y)) === 0,
)!;
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge });
expect(world.move("left")).toBe(false);
expect(world.notice).toBe("Route 29 is not ported yet.");
const {
scenes,
phone,
clock,
objects,
playerId,
party,
bag,
pokedex,
...legacy
} = world.save();
void [scenes, phone, objects, playerId, party, bag, pokedex];
world.restore({ ...legacy, version: 1 });
expect(world.save()).toMatchObject({
version: 3,
scenes: {},
phone: [],
objects: [],
party: [],
pokedex: { seen: [], caught: [] },
});
world.restore({
...legacy,
version: 2,
scenes: { "24:6": 1 },
phone: [1],
clock,
objects: [],
});
expect(world.save()).toMatchObject({
version: 3,
scenes: { "24:6": 1 },
phone: [1],
});
expect(world.save().playerId).toBeLessThan(0x10000);
expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid");
});
it("decodes species, moves, items and growth from the ROM", () => {
const data = load().pokemon;
expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([
"CHIKORITA",
"CYNDAQUIL",
"TOTODILE",
]);
expect(data.speciesName(122)).toBe("MR.MIME");
expect(data.baseData(155)).toMatchObject({
species: 155,
stats: [39, 52, 43, 65, 60, 50],
growthRate: 3,
picSize: 5,
});
// Medium Slow: 135 at level 5, and the original level-1 underflow.
expect(data.expAtLevel(3, 5)).toBe(135);
expect(data.expAtLevel(3, 1)).toBe(16777162);
expect(data.expAtLevel(0, 100)).toBe(1_000_000);
expect(data.learnset(155).slice(0, 2)).toEqual([
{ level: 1, move: 33 },
{ level: 1, move: 43 },
]);
expect(data.moveName(33)).toBe("TACKLE");
expect(data.move(33)).toMatchObject({ power: 35, accuracy: 242, pp: 35 });
expect(data.itemName(18)).toBe("POTION");
expect(data.itemPocket(18)).toBe("item");
expect(data.pocketName("item")).toBe("ITEM POCKET");
expect(data.frontPic(155)).toHaveLength(49 * 16);
expect(() => data.speciesName(252)).toThrow("Unknown");
});
it.skipIf(
!existsSync(
".reference/pokecrystal/gfx/pokemon/cyndaquil/front.animated.2bpp",
),
)(
"matches front pictures against the locally rebuilt PRET reference",
() => {
const data = load().pokemon;
for (const [species, name] of [
[152, "chikorita"],
[155, "cyndaquil"],
[158, "totodile"],
] as const)
expect(data.frontPicData(species)).toEqual(
new Uint8Array(
readFileSync(
`.reference/pokecrystal/gfx/pokemon/${name}/front.animated.2bpp`,
),
),
);
},
);
it("gives the chosen starter and the aide's Potion, then saves them", () => {
let seed = 7;
const random = () => (seed = (seed * 73 + 41) & 255);
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
random,
);
world.restore({
...world.save(),
map: "24:5",
x: 4,
y: 3,
scenes: { "24:6": 1, "24:5": 1 },
});
walkTo(world, 6, 4);
expect(world.move("up")).toBe(false);
world.interact();
// pokepic shows the ROM's front picture and waits for a button.
expect(world.picture).toBe(155);
expect(world.dialogue?.pages).toEqual([""]);
world.advanceDialogue();
expect(world.picture).toBeUndefined();
expect(world.dialogue?.choice?.kind).toBe("yesno");
// Take it, then give it a nickname through the naming screen.
const pages = settle(world, [1, 1]);
expect(pages).toContain("CHRIS received\nCYNDAQUIL!");
expect(world.dialogue?.choice).toMatchObject({
kind: "name",
current: "CYNDAQUIL",
});
expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]);
// Characters the naming screen cannot enter keep the prompt open.
world.choose("BAD~NAME");
expect(world.dialogue?.choice?.kind).toBe("name");
world.choose("BLAZE");
expect(settle(world)).toContain("CHRIS got ELM's\nphone number.");
const save = world.save();
expect(save.party).toHaveLength(1);
expect(save.party[0]).toMatchObject({
species: 155,
nickname: "BLAZE",
ot: "CHRIS",
otId: world.playerId,
level: 5,
exp: 135,
item: 173,
moves: [33, 43, 0, 0],
pp: [35, 30, 0, 0],
happiness: 70,
// Day, level 5; male player in New Bark Town.
caughtData: 0x8501,
});
expect(save.party[0].hp).toBe(save.party[0].stats[0]);
expect(save.pokedex).toEqual({ seen: [155], caught: [155] });
expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 });
expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM
// The other balls now only describe themselves.
walkTo(world, 7, 4);
world.move("up");
world.interact();
expect(settle(world)).toHaveLength(1);
expect(world.save().party).toHaveLength(1);
walkTo(world, 4, 7);
expect(world.move("down")).toBe(true);
expect(settle(world)).toEqual([
"CHRIS, I want\nyou to have this\nfor your errand.",
"CHRIS received\nPOTION.",
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
"There are only two\nof us, so we're\nalways busy.",
]);
const final = world.save();
expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]);
expect(final.scenes["24:5"]).toBe(2);
const restored = new GameWorld(
world.assets,
() => new Date("2026-09-22T12:00:00"),
);
restored.restore(final);
expect(restored.save()).toEqual(final);
// Stored stats must match the ROM's formula for the stored DVs.
const tampered = structuredClone(final);
tampered.party[0].stats[1] += 1;
expect(() => restored.restore(tampered)).toThrow("Invalid");
const potion = structuredClone(final);
potion.bag.keyItems = [18];
expect(() => restored.restore(potion)).toThrow("Invalid");
expect(restored.save()).toEqual(final);
});
it("reads the bedroom radio through native event and text interpreters", () => {
const world = new GameWorld(load());
world.restore({ ...world.save(), x: 3, y: 2, direction: "up" });
const previousEvents = world.state.events.size;
world.interact();
expect(world.diagnostic).toBe("");
// Each radio line holds for 45 ticks (pause 45), ignoring buttons.
expect(world.dialogue?.holdTicks).toBe(45);
world.advanceDialogue();
expect(world.dialogue?.holdTicks).toBe(45);
expect(world.state.events.size).toBe(previousEvents);
expect(settle(world)).toHaveLength(4);
expect(world.state.events.size).toBe(previousEvents + 1);
world.interact();
expect(settle(world)).toHaveLength(1);
});
},
);