Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 16:09:22 +02:00
1 parent 8b80d05ca2
commit c08d250cf6
39 files changed
+10727 -1524

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import {
Battle,
UnsupportedBattle,
type BattleEffect,
type BattleInput,
} from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import {
calcStats,
generatePartyMon,
type PartyMon,
} from "../src/game/party/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"native battle engine with the local ROM",
() => {
let data: CrystalAssets["pokemon"];
beforeAll(() => {
data = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
});
const PIDGEY = 16,
CYNDAQUIL = 155,
TACKLE = 33;
const starter = (level = 5, exp?: number): PartyMon => {
const mon = generatePartyMon(data, {
species: CYNDAQUIL,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
if (exp !== undefined) mon.exp = exp;
return mon;
};
/** A reproducible stand-in for the hardware Random routine. */
const seeded = (seed: number) => () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const battle = (
party: PartyMon[],
wild: { species: number; level: number },
seed = 1,
) => {
const pokedex = { seen: new Set<number>(), caught: new Set<number>() };
return {
pokedex,
battle: new Battle({
data,
party,
pokedex,
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random: seeded(seed),
wild,
text: () => defaultTextContext("CHRIS"),
}),
};
};
/** Play a battle, answering the menu and prompts; returns texts and result. */
const play = (
battle: Battle,
answer: (effect: BattleEffect) => BattleInput = (effect) =>
effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: undefined,
) => {
const texts: string[] = [];
const effects: BattleEffect[] = [];
const run = battle.run();
let input: BattleInput;
for (let step = 0; step < 2000; step++) {
const next = run.next(input);
if (next.done) return { texts, effects, result: next.value.result };
effects.push(next.value);
if (next.value.kind === "text")
texts.push(next.value.text.replace(/\n/g, " "));
input = answer(next.value);
}
throw new Error("Battle did not end.");
};
it("wins a wild battle with the original messages, experience and stat experience", () => {
const mon = starter();
const { battle: game, pokedex } = battle([mon], {
species: PIDGEY,
level: 3,
});
const { texts, result } = play(game);
expect(texts.slice(0, 3)).toEqual([
"Wild PIDGEY appeared!",
"Go! CYNDAQUIL!",
"CYNDAQUIL used TACKLE!",
]);
expect(texts).toContain("Enemy PIDGEY fainted!");
// Base experience 55 × level 3 ÷ 7.
expect(texts.at(-1)).toBe("CYNDAQUIL gained 23 EXP. Points!");
expect(result).toBe(C.WIN);
expect(mon.exp).toBe(135 + 23);
expect(mon.statExp).toEqual([40, 45, 40, 56, 35]);
expect(mon.pp[0]).toBeLessThan(35);
expect(pokedex.seen.has(PIDGEY)).toBe(true);
expect(mon.hp).toBeLessThan(mon.stats[0]);
});
it("escapes when the player's Pokémon is faster", () => {
const mon = starter(20);
const { battle: game } = battle([mon], { species: PIDGEY, level: 2 });
const { texts, result } = play(game, (effect) =>
effect.kind === "action" ? { action: "run" } : undefined,
);
expect(texts.slice(-1)).toEqual(["Got away safely!"]);
expect(result).toBe(C.DRAW);
expect(mon.exp).toBe(data.expAtLevel(3, 20));
});
it("loses when the only Pokémon faints", () => {
const mon = starter();
mon.hp = 1;
const { battle: game } = battle([mon], { species: 19, level: 10 }, 5);
const { texts, result } = play(game, (effect) =>
effect.kind === "action" ? { action: "fight", move: 1 } : undefined,
);
expect(texts).toContain("CYNDAQUIL fainted!");
expect(result).toBe(C.LOSE);
expect(mon.hp).toBe(0);
expect(mon.status).toBe(0);
});
it("levels up, shows the stat box and learns the level's move", () => {
const mon = starter(5, 178);
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
const { texts, effects } = play(game);
expect(texts).toContain("CYNDAQUIL grew to level 6!");
const box = effects.find((effect) => effect.kind === "stats");
expect(box).toMatchObject({
labels: ["ATTACK", "DEFENSE", "SPCL.ATK", "SPCL.DEF", "SPEED"],
});
expect(texts).toContain("CYNDAQUIL learned SMOKESCREEN!");
expect(mon.level).toBe(6);
expect(mon.moves).toEqual([TACKLE, 43, 108, 0]);
// Level-up recalculates stats with the new stat experience.
expect(mon.stats).toEqual(
calcStats(data, CYNDAQUIL, mon.dvs, 6, mon.statExp),
);
});
it("asks which move to forget when four moves are known", () => {
const mon = starter(5, 178);
mon.moves = [TACKLE, 43, 52, 98];
mon.pp = [35, 30, 25, 30];
const { battle: game } = battle([mon], { species: PIDGEY, level: 3 });
const { texts } = play(game, (effect) =>
effect.kind === "action"
? { action: "fight", move: 0 }
: effect.kind === "yesno"
? 1
: effect.kind === "forget"
? 1
: undefined,
);
expect(texts).toContain("Which move should be forgotten?");
expect(texts.some((text) => text.startsWith("1, 2 and…"))).toBe(true);
expect(mon.moves).toEqual([TACKLE, 108, 52, 98]);
expect(mon.pp[1]).toBe(data.move(108).pp);
});
it("refuses battles that need untranslated rules", () => {
const { battle: game } = battle([starter()], { species: 129, level: 5 });
expect(() => play(game)).toThrow(UnsupportedBattle);
});
},
);
+33 -2
View File
@@ -6,8 +6,8 @@ test("ROM gate, help and responsive layouts", async ({ page }) => {
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(
page.getByRole("heading", { name: "A little adventure. Anywhere." }),
).toBeVisible();
page.getByRole("tab", { name: "Game", exact: true }),
).toHaveAttribute("aria-selected", "true");
await expect(page.locator("#play")).toBeDisabled();
await page.locator("#rom-file").setInputFiles({
name: "wrong.gbc",
@@ -24,6 +24,7 @@ test("ROM gate, help and responsive layouts", async ({ page }) => {
[768, 1024],
[390, 844],
[320, 740],
[320, 568],
[844, 390],
]) {
await page.setViewportSize({ width, height });
@@ -44,10 +45,40 @@ test("ROM gate, help and responsive layouts", async ({ page }) => {
expect(button.width).toBeGreaterThanOrEqual(44);
expect(button.height).toBeGreaterThanOrEqual(44);
}
await page.locator("#player-name").scrollIntoViewIfNeeded();
await expect(page.locator("#player-name")).toBeInViewport({ ratio: 0.9 });
await page.locator("#rom-label").scrollIntoViewIfNeeded();
await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 });
}
expect(errors).toEqual([]);
});
test("game menu tabs expose real tools and honest planned states with keyboard navigation", async ({
page,
}) => {
await page.goto("/");
const game = page.getByRole("tab", { name: "Game", exact: true });
await game.focus();
await page.keyboard.press("ArrowRight");
await expect(page.locator("#tab-saves")).toBeFocused();
await expect(page.locator("#panel-saves")).toBeVisible();
await expect(page.locator("#save")).toBeDisabled();
await expect(page.locator("#backup")).toBeDisabled();
await page.keyboard.press("ArrowRight");
await expect(page.locator("#panel-map")).toBeVisible();
await expect(page.locator("#map-empty")).toBeVisible();
await page.keyboard.press("End");
await expect(page.locator("#tab-friends")).toBeFocused();
await expect(page.locator("#panel-friends")).toContainText(/planned/i);
await page.keyboard.press("Home");
await expect(game).toBeFocused();
await expect(page.locator("#panel-game")).toBeVisible();
for (const id of ["pokedex", "link"]) {
await page.locator(`#tab-${id}`).click();
await expect(page.locator(`#panel-${id}`)).toContainText(/planned/i);
}
});
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
page,
}) => {
+366
View File
@@ -0,0 +1,366 @@
import { describe, expect, it } from "vitest";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
override permission(collision: number) {
return collision === 7 ? 15 : 0;
}
override grassCollisions() {
return [];
}
override sprite() {
return { graphics: new Uint8Array(0), palette: 0, walking: false };
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return { assets, world };
}
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
describe("native ledge movement", () => {
it.each<[number, Direction]>([
[0xa0, "right"],
[0xa1, "left"],
[0xa2, "up"],
[0xa3, "down"],
[0xa4, "right"],
[0xa4, "down"],
[0xa5, "down"],
[0xa5, "left"],
[0xa6, "up"],
[0xa6, "right"],
[0xa7, "up"],
[0xa7, "left"],
])(
"jumps from ledge %i toward %s over two tiles in sixteen ticks",
(tile, direction) => {
const { assets, world } = fixture();
const [dx, dy] = VECTORS[direction];
assets.tile(3, 3, tile);
assets.tile(3 + dx, 3 + dy, 7);
world.tick(direction);
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
x: 48 + dx * 2,
y: 48 + dy * 2,
lift: -6,
shadow: dx ? 12 : 14,
});
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.busy).toBe(true);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
},
);
it("walks onto ledges and prefers a normal step when the next tile is clear", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0xa3);
expect(world.move("down")).toBe(true);
world.tick("down");
expect([world.x, world.y]).toEqual([3, 5]);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
settle(world);
expect(world.tilesWalked).toBe(2);
});
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 2, 7);
expect(world.move("up")).toBe(false);
assets.tile(3, 4, 7);
for (const landing of [7, 0x30, 0x40, 0x60]) {
assets.tile(3, 5, landing);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
}
assets.tile(3, 6, 0xa3);
assets.tile(3, 7, 7);
world.restore({ ...world.save(), y: 6 });
expect(world.move("down")).toBe(false);
});
it("does not land on an occupied tile", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 4, 7);
assets.map("24:7").objects.push({
id: 2,
x: 3,
y: 5,
sprite: 1,
movement: 6,
palette: 0,
kind: 0,
script: 0,
eventFlag: 0xffff,
hour1: 0xff,
hour2: 0xff,
});
world.restore(world.save());
expect(world.move("down")).toBe(false);
expect(world.y).toBe(3);
});
it("replays an interrupted jump and its landing scene without skipping event writes", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 4, 7);
// pause 4; setevent 8; end
assets.bytes.set([0x8b, 4, 0x33, 8, 0, 0x91], 0x100);
assets
.map("24:7")
.coordinateEvents.push({ x: 3, y: 5, scene: 0xff, script: 0x100 });
world.tick("down");
const beforeLanding = world.save();
const restored = new GameWorld(assets);
restored.restore(beforeLanding);
expect(restored.move("down")).toBe(true);
expect(restored.activity).toBe("scene");
expect(restored.save()).toEqual(beforeLanding);
expect(restored.state.events.has(8)).toBe(false);
settle(restored);
expect(restored.state.events.has(8)).toBe(true);
expect(restored.save()).toMatchObject({ x: 3, y: 5, tilesWalked: 1 });
});
});
describe("native warp movement", () => {
it.each<[number, Direction]>([
[0x70, "down"],
[0x76, "left"],
[0x78, "up"],
[0x7e, "right"],
])("carpet %i waits for a blocked step toward %s", (tile, direction) => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, tile);
expect(world.move("down")).toBe(true);
expect(world.mapId).toBe("24:7");
settle(world);
expect(world.mapId).toBe("24:7");
const [dx, dy] = VECTORS[direction];
assets.tile(3 + dx, 4 + dy, 7);
expect(world.move(direction)).toBe(true);
expect(world.mapId).toBe("24:6");
expect([world.x, world.y, world.direction, world.tilesWalked]).toEqual([
2,
2,
direction,
1,
]);
settle(world);
// Arrival on a mat neither teleports inward nor warps back.
expect([world.x, world.y]).toEqual([2, 2]);
});
it("lets the player walk away from a mat and refuses the wrong exit direction", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x70);
world.move("down");
assets.tile(2, 4, 7);
expect(world.move("left")).toBe(false);
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe("24:7");
expect(world.y).toBe(3);
});
it("uses carpet warps on the boundary without moving outside the map", () => {
const { assets, world } = fixture();
connectWarp(assets, 3, 7);
assets.tile(3, 7, 0x70);
world.restore({ ...world.save(), y: 7 });
world.tick("down");
expect(world.mapId).toBe("24:6");
expect(world.tilesWalked).toBe(0);
expect(() => world.restore(world.save())).not.toThrow();
});
it.each([0x71, 0x79, 0x7a, 0x7b])(
"walks down off arrival tile %i over eight ticks without input",
(tile) => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x72);
assets.tile(2, 2, tile, "24:6");
world.move("down");
expect([world.mapId, world.x, world.y, world.direction]).toEqual([
"24:6",
2,
2,
"down",
]);
world.tick("up"); // Forced exit overrides held input.
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
const save = world.save();
expect(save).toMatchObject({ x: 2, y: 2, tilesWalked: 1 });
world.restore(save);
settle(world);
expect(world.save()).toMatchObject({ x: 2, y: 3, tilesWalked: 2 });
},
);
it("saves before a doorway until the walking animation and warp complete", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x71);
world.tick("down");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 3, y: 3, tilesWalked: 0 });
world.restore(save);
expect(world.move("down")).toBe(true);
expect(world.save()).toMatchObject({
map: "24:6",
x: 2,
y: 2,
tilesWalked: 1,
});
});
it("saves on the source map during a connection crossing", () => {
const { assets, world } = fixture();
assets
.map("24:7")
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
world.restore({ ...world.save(), x: 7 });
world.tick("right");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
expect(() => world.restore(save)).not.toThrow();
expect(world.move("right")).toBe(true);
expect(world.save()).toMatchObject({
map: "24:6",
x: 0,
y: 3,
tilesWalked: 1,
});
});
it("restores early version-3 backups without money but rejects corrupt balances", () => {
const { world } = fixture();
const { money, ...earlySave } = world.save();
world.state.money = 42;
world.restore(earlySave);
expect(world.state.money).toBe(money);
for (const invalid of [null, -1, 1.5, 1_000_000, "3000"]) {
expect(() => world.restore({ ...earlySave, money: invalid })).toThrow(
"Invalid native save",
);
expect(world.state.money).toBe(money);
}
});
it("refuses unavailable destinations and bad callbacks before entering a doorway", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x71);
const warp = world.map.warps[0];
for (const targetWarp of [0, 255, 2]) {
warp.targetWarp = targetWarp;
expect(world.move("down")).toBe(false);
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 3,
tilesWalked: 0,
});
}
warp.targetWarp = 1;
warp.targetMap = "1:1";
expect(world.move("down")).toBe(false);
warp.targetMap = "24:6";
assets.bytes[0x100] = 0xff;
assets.map("24:6").callbacks.push({ kind: 5, script: 0x100 });
expect(world.move("down")).toBe(false);
expect(world.notice).toContain("progress is unchanged");
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 3,
tilesWalked: 0,
});
// A directional carpet can be entered, but its failed exit is also atomic.
assets.tile(3, 4, 0x70);
assets.tile(3, 5, 7);
expect(world.move("down")).toBe(true);
expect(world.move("down")).toBe(false);
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 4,
tilesWalked: 1,
});
});
});
+409 -7
View File
@@ -1,12 +1,367 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types";
import { settle } from "./world-driver";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("local maps follow a stairs transition and importing a previous position", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
// Find a walkable route in the local fixture, then drive the actual UI.
const world = new GameWorld(
new CrystalAssets(new Uint8Array(readFileSync(romPath))),
);
const target = world.map.warps[0];
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
const seen = new Set<string>();
let path: Direction[] | undefined;
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
for (let i = 0; i < queue.length; i++) {
const node = queue[i];
const key = `${node.x},${node.y}`;
if (seen.has(key)) continue;
seen.add(key);
if (node.x === target.x && node.y === target.y) {
path = node.path;
break;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
expect(path).toBeDefined();
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await downloaded;
await page.locator("#play").click();
for (const direction of path!) {
const [dx, dy] = VECTORS[direction];
const stepX = world.x + dx,
stepY = world.y + dy;
expect(world.move(direction)).toBe(true);
settle(world);
await page.keyboard.down(
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
);
// Release as the step starts, before a warp and its forced exit complete.
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-x",
String(stepX),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-y",
String(stepY),
);
await page.keyboard.up(
`Arrow${direction[0].toUpperCase()}${direction.slice(1)}`,
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map",
world.mapId,
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-x",
String(world.x),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-y",
String(world.y),
);
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-activity",
"idle",
);
}
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:6");
await page.locator("#tab-map").click();
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:6");
await expect(page.locator("#map-location")).toHaveText(
`${world.map.name} · ${world.map.location}`,
);
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#tab-map").click();
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7");
await expect(page.locator("#world-map")).toHaveAttribute("data-x", "3");
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "3");
});
test("Mom's opening scene runs with native keyboard and pointer choices", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
const step = async (key: string, map: string, x: number, y: number) => {
await page.keyboard.down(key);
await expect(screen).toHaveAttribute("data-map", map);
await expect(screen).toHaveAttribute("data-x", String(x));
await expect(screen).toHaveAttribute("data-y", String(y));
await page.keyboard.up(key);
};
// Bedroom (3,3) to the stairs, then down toward Mom.
await step("ArrowUp", "24:7", 3, 2);
for (const x of [4, 5, 6, 7]) await step("ArrowRight", "24:7", x, 2);
await step("ArrowUp", "24:7", 7, 1);
await step("ArrowUp", "24:7", 7, 0);
await expect(screen).toHaveAttribute("data-map", "24:6");
await expect(screen).toHaveAttribute("data-y", "1");
await expect(screen).toHaveAttribute("data-activity", "idle");
for (const y of [2, 3, 4]) await step("ArrowDown", "24:6", 9, y);
const dialogue = page.locator("#native-dialogue");
await expect(dialogue).toBeVisible();
const seen: string[] = [];
for (let guard = 0; guard < 60 && (await dialogue.isVisible()); guard++) {
const text = (await page.locator("#dialogue-text").textContent()) ?? "";
seen.push(text);
const choice = page.locator("#dialogue-choice");
if (await choice.isVisible()) {
if (await choice.locator("output").count()) {
// Weekday box: Up selects the next day, X confirms.
await expect(choice.locator("output")).toHaveText("SUNDAY");
await page.keyboard.press("ArrowUp");
await expect(choice.locator("output")).toHaveText("MONDAY");
await page.keyboard.press("KeyX");
} else {
await choice.getByRole("button", { name: "YES" }).click();
}
} else await page.keyboard.press("KeyX");
await expect
.poll(async () =>
(await dialogue.isVisible())
? await page.locator("#dialogue-text").textContent()
: "closed",
)
.not.toBe(text);
}
expect(seen).toContain("MONDAY, is it?");
expect(seen.some((text) => text.includes("received"))).toBe(true);
await expect(dialogue).toBeHidden();
// Mom walks back before the scene commits.
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
// The completed scene persists in the exported native save.
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(save).toMatchObject({
version: 3,
map: "24:6",
scenes: { "24:6": 1 },
phone: [1],
});
expect(save.clock.dst).toBe(true);
expect(errors).toEqual([]);
});
test("choosing a starter shows its picture, asks for a nickname and saves the party", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
// Start in Elm's lab after his introduction, facing Cyndaquil's ball.
const bytes = readFileSync(romPath);
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
const save = {
...world.save(),
map: "24:5",
x: 6,
y: 4,
direction: "up",
scenes: { "24:6": 1, "24:5": 1 },
};
const backup = testInfo.outputPath("lab.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(save)).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
const picture = page.locator("#pokepic");
await expect(picture).toBeVisible();
await expect(page.locator("#pokepic-canvas")).toHaveAttribute(
"aria-label",
"CYNDAQUIL",
);
// The picture is drawn from ROM tiles, not left blank.
expect(
await page.locator("#pokepic-canvas").evaluate((element) => {
const canvas = element as HTMLCanvasElement;
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 56, 56).data;
const colors = new Set<number>();
for (let i = 0; i < pixels.length; i += 4)
colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
return colors.size;
}),
).toBeGreaterThanOrEqual(3);
await page.locator("#pokepic-next").click();
await expect(picture).toBeHidden();
await expect(page.locator("#dialogue-text")).toContainText("CYNDAQUIL");
await page.getByRole("button", { name: "YES" }).click();
const nickname = page.locator("#dialogue-choice input");
for (let guard = 0; guard < 12 && !(await nickname.count()); guard++) {
const choice = page.getByRole("button", { name: "YES" });
if (await choice.count()) await choice.click();
else await page.keyboard.press("KeyX");
await page.waitForTimeout(100);
}
await expect(page.locator("#dialogue-text")).toHaveText(
"CYNDAQUIL'S\nNICKNAME?",
);
await expect(nickname).toBeFocused();
await nickname.fill("BLAZE");
await nickname.press("Enter");
const screen = page.locator("#game-screen");
for (let guard = 0; guard < 30; guard++) {
if ((await screen.getAttribute("data-activity")) === "idle") break;
if (await page.locator("#native-dialogue").isVisible())
await page.keyboard.press("KeyX");
await page.waitForTimeout(120);
}
await expect(screen).toHaveAttribute("data-activity", "idle");
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const saved = JSON.parse(
Buffer.from(record.state, "base64").toString("utf8"),
);
expect(saved.party).toHaveLength(1);
expect(saved.party[0]).toMatchObject({
species: 155,
nickname: "BLAZE",
level: 5,
});
expect(saved.scenes["24:5"]).toBe(5);
expect(errors).toEqual([]);
});
test("a home exit mat survives save/reload and waits for the exit direction", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const bytes = readFileSync(romPath);
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
world.restore({
...world.save(),
map: "24:6",
x: 6,
y: 6,
scenes: { "24:6": 1 },
});
const backup = testInfo.outputPath("home.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(world.save())).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
await page.keyboard.down("ArrowDown");
await expect(screen).toHaveAttribute("data-y", "7");
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(screen).toHaveAttribute("data-map", "24:6");
await page.locator("#tab-saves").click();
await page.locator("#save").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(screen).toHaveAttribute("data-map", "24:6");
await expect(screen).toHaveAttribute("data-y", "7");
await page.locator("#play").click();
// Walking back off the carpet is allowed and does not leave the house.
await page.keyboard.down("ArrowUp");
await expect(screen).toHaveAttribute("data-y", "6");
await page.keyboard.up("ArrowUp");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(screen).toHaveAttribute("data-map", "24:6");
await page.keyboard.down("ArrowDown");
await expect(screen).toHaveAttribute("data-y", "7");
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-activity", "idle");
await page.keyboard.down("ArrowDown");
await page.waitForFunction(
() =>
document.querySelector("#game-screen")?.getAttribute("data-map") ===
"24:4",
);
await page.keyboard.up("ArrowDown");
await expect(screen).toHaveAttribute("data-x", "13");
await expect(screen).toHaveAttribute("data-y", "6");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#game-notice")).toHaveText("");
});
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
@@ -40,19 +395,30 @@ test("native player loads the actual ROM without loading an emulator and persist
"data-map",
"24:7",
);
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7");
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.keyboard.up("ArrowUp");
await page.waitForTimeout(150);
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
// A is ignored mid-step, as in the original; wait for the step to end.
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-activity",
"idle",
);
await page.keyboard.press("KeyX");
// The radio's lines hold for `pause 45` each and advance by themselves.
await expect(page.locator("#native-dialogue")).toBeVisible();
await expect(page.locator("#dialogue-page")).toHaveText("1 / 4");
for (let i = 0; i < 4; i++) await page.locator("#dialogue-next").click();
await expect(page.locator("#native-dialogue")).toBeHidden();
await expect(page.locator("#dialogue-text")).toContainText("TALK!");
await expect(page.locator("#dialogue-next")).toBeHidden();
await expect(page.locator("#native-dialogue")).toBeHidden({
timeout: 10_000,
});
await page.locator("#play").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.locator("#tab-saves").click();
const exported = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await exported;
@@ -63,15 +429,30 @@ test("native player loads the actual ROM without loading an emulator and persist
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await expect(page.locator("#minimap")).toHaveAttribute("data-y", "2");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-page")).toHaveText("1 / 1");
await page.locator("#dialogue-next").click();
await expect(page.locator("#dialogue-text")).toContainText("POKéMON");
await expect(page.locator("#native-dialogue")).toBeHidden({ timeout: 5_000 });
// Opening any menu pauses and releases game input, even while a key is held.
await page.keyboard.down("ArrowUp");
await page.locator("#tab-map").click();
await expect(page.locator("#power-label")).toHaveText("PAUSED");
await expect(page.locator("#world-map")).toHaveAttribute("data-y", "2");
await expect(page.locator("#map-empty")).toBeHidden();
await expect(page.locator('[data-game-button="up"]')).toHaveAttribute(
"aria-pressed",
"false",
);
await page.keyboard.up("ArrowUp");
await page.locator("#tab-game").click();
await expect(page.locator("#pause-overlay")).toBeVisible();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[390, 844],
[320, 740],
[320, 568],
[844, 390],
]) {
await page.setViewportSize({ width, height });
@@ -84,6 +465,27 @@ test("native player loads the actual ROM without loading an emulator and persist
if (width <= 390 || height <= 390) {
const box = (await page.locator(".console").boundingBox())!;
expect(box.y + box.height).toBeLessThanOrEqual(height);
const toolbar = (await page.locator(".global-bar").boundingBox())!;
expect(toolbar.y + toolbar.height).toBeLessThanOrEqual(height);
}
// Responsive native rendering draws a different area, not a stretched GB frame.
await expect
.poll(async () =>
page.locator("#game-screen").evaluate((el) => {
const canvas = el as HTMLCanvasElement;
const bounds = canvas.getBoundingClientRect();
return Math.abs(
canvas.width / canvas.height - bounds.width / bounds.height,
);
}),
)
.toBeLessThan(0.03);
if (width >= 1024) {
expect(
await page
.locator("#game-screen")
.evaluate((el) => (el as HTMLCanvasElement).width),
).toBeGreaterThan(160);
}
}
await page.setViewportSize({ width: 390, height: 844 });
+502 -88
View File
@@ -6,9 +6,17 @@ 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 { runScript } from "../src/game/scripts/interpreter";
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", () => {
@@ -57,24 +65,73 @@ describe("native ROM formats", () => {
});
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 = {
events: new Set<number>(),
flags: new Set<number>(),
variables: {},
};
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(() =>
runScript(
reader,
0,
{ events: new Set(), flags: new Set(), variables: {} },
"TEST",
),
).toThrow("budget");
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
).toThrow("no native value");
});
});
@@ -129,84 +186,438 @@ describe.skipIf(!existsSync(romPath))(
);
},
);
it("walks from the bedroom to downstairs and the town, with collision and return warps", () => {
it("walks with the original timing: eight overworld ticks per tile", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
expect(world.x).toBe(3);
expect(world.y).toBe(3);
expect(world.move("up")).toBe(true);
const steps = world.tilesWalked;
expect(world.move("up")).toBe(false); // Radio/furniture is solid.
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]);
expect(world.move("down")).toBe(true);
const routeTo = (targetX: number, targetY: number) => {
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
const queue = [
{
x: world.x,
y: world.y,
path: [] as ("up" | "down" | "left" | "right")[],
},
];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) {
for (const direction of node.path)
expect(world.move(direction)).toBe(true);
return;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(
world.assets.collision(world.map, x, y),
) !== 0
)
continue;
if (
world.objects().some((object) => object.x === x && object.y === y)
)
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
};
routeTo(7, 0);
expect(world.mapId).toBe("24:6");
routeTo(6, 7);
expect(world.mapId).toBe("24:4");
expect(world.tilesWalked).toBeGreaterThan(10);
const arrival = world.map.warps.find(
(warp) => warp.targetMap === "24:6",
)!;
routeTo(arrival.x, arrival.y);
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(() => world.restore({ ...save, x: 127 })).toThrow("position");
expect(world.save()).toEqual(save);
const restored = new GameWorld(world.assets);
restored.restore(save);
expect(restored.save()).toEqual(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());
@@ -214,12 +625,15 @@ describe.skipIf(!existsSync(romPath))(
const previousEvents = world.state.events.size;
world.interact();
expect(world.diagnostic).toBe("");
expect(world.dialogue?.pages.length).toBe(4);
// 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);
while (world.dialogue) world.advanceDialogue();
expect(settle(world)).toHaveLength(4);
expect(world.state.events.size).toBe(previousEvents + 1);
world.interact();
expect(world.dialogue?.pages.length).toBe(1);
expect(settle(world)).toHaveLength(1);
});
},
);
+190 -1
View File
@@ -1,5 +1,9 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { BattleData } from "../src/game/battle/data";
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { resolve } from "node:path";
const romPath = resolve(
@@ -104,3 +108,188 @@ test("native decompression matches original GB assembly executing in the referen
).toBe(true);
}
});
test("native damage and type effectiveness match the original battle routines", async ({
page,
}) => {
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
const symbols = new Map(
readFileSync(symbolsPath, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
return match
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
: [];
}),
);
const at = (name: string) => symbols.get(name)!.address;
const routine = (name: string) => symbols.get(name)!;
const bytes = new Uint8Array(readFileSync(romPath));
const assets = new CrystalAssets(bytes);
const data = new BattleData(assets.reader);
const matchups = data.typeMatchups();
// A deterministic generator keeps failures reproducible.
let seed = 0x1234;
const next = (limit: number) => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 8) % limit;
};
const types = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27];
const effects = [0, 0, 0, C.EFFECT_SELFDESTRUCT, C.EFFECT_MULTI_HIT, C.EFFECT_CONVERSION];
const damageCases = Array.from({ length: 400 }, () => {
const item = next(4) === 0 ? next(191) : 0;
return {
power: next(8) === 0 ? 0 : next(256),
level: 1 + next(100),
attack: next(256),
defense: next(256),
effect: effects[next(effects.length)],
critical: next(2),
current: next(5) === 0 ? next(1200) : 0,
type: types[next(types.length)],
item,
};
});
const stabCases = Array.from({ length: 400 }, () => ({
move: 1 + next(164),
type: types[next(types.length)],
user: [types[next(types.length)], types[next(types.length)]],
target: [types[next(types.length)], types[next(types.length)]],
identified: next(4) === 0,
damage: next(1000),
}));
await page.goto("/reference.html");
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
const addresses = {
curDamage: at("wCurDamage"),
critical: at("wCriticalHit"),
turn: at("hBattleTurn"),
playerMove: at("wPlayerMoveStruct"),
battleItem: at("wBattleMonItem"),
battleTypes: at("wBattleMonType1"),
enemyTypes: at("wEnemyMonType1"),
typeModifier: at("wTypeModifier"),
attackMissed: at("wAttackMissed"),
weather: at("wBattleWeather"),
johto: at("wJohtoBadges"),
kanto: at("wKantoBadges"),
link: at("wLinkMode"),
tower: at("wInBattleTowerBattle"),
enemySub1: at("wEnemySubStatus1"),
damageCalc: routine("BattleCommand_DamageCalc"),
stab: routine("BattleCommand_Stab"),
};
const results = await page.evaluate(
async ({ addresses, damageCases, stabCases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
const reference = new BinjgbEmulator();
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
const word = (address: number, value: number) => {
write(address, value >> 8);
write(address + 1, value);
};
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set B, C, D, E;
// CALL the original routine; write a sentinel; loop.
const call = (target: { bank: number; address: number }, registers: number[]) => {
const program = [
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
0x06, registers[0], 0x0e, registers[1], 0x16, registers[2], 0x1e, registers[3],
0xcd, target.address & 0xff, target.address >> 8,
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
];
write(0xc6ff, 0);
program.forEach((value, index) => write(0xc000 + index, value));
reference.setProgramCounter(0xc000);
reference.advanceFrames(2);
return reference.readMemory(0xc6ff) === 0x42;
};
write(0xff70, 1); // WRAM bank 1 holds wCurDamage and wEnemyMon
for (const address of [addresses.weather, addresses.johto, addresses.kanto, addresses.link, addresses.tower])
write(address, 0);
write(addresses.turn, 0);
const damage = damageCases.map((input) => {
write(addresses.playerMove + 1, input.effect);
write(addresses.playerMove + 3, input.type);
write(addresses.battleItem, input.item);
write(addresses.critical, input.critical);
word(addresses.curDamage, input.current);
// B = attack, C = defense, D = power, E = level
const returned = call(addresses.damageCalc, [input.attack, input.defense, input.power, input.level]);
return {
returned,
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
};
});
const stab = stabCases.map((input) => {
write(addresses.playerMove, input.move);
write(addresses.playerMove + 3, input.type);
write(addresses.battleTypes, input.user[0]);
write(addresses.battleTypes + 1, input.user[1]);
write(addresses.enemyTypes, input.target[0]);
write(addresses.enemyTypes + 1, input.target[1]);
write(addresses.enemySub1, input.identified ? 1 << 3 : 0);
write(addresses.typeModifier, 10);
write(addresses.attackMissed, 0);
word(addresses.curDamage, input.damage);
const returned = call(addresses.stab, [0, 0, 0, 0]);
return {
returned,
damage: (reference.readMemory(addresses.curDamage) << 8) | reference.readMemory(addresses.curDamage + 1),
modifier: reference.readMemory(addresses.typeModifier),
missed: reference.readMemory(addresses.attackMissed),
};
});
reference.dispose();
return { damage, stab };
},
{ addresses, damageCases, stabCases },
);
damageCases.forEach((input, index) => {
const original = results.damage[index];
expect(original.returned, `damage case ${index} returned`).toBe(true);
const held = data.heldEffect(input.item);
const boost =
held.effect && data.typeBoost(held.effect) === input.type ? held.parameter : undefined;
const native =
calculateDamage({
power: input.power,
level: input.level,
attack: input.attack,
defense: input.defense,
effect: input.effect,
critical: Boolean(input.critical),
boost,
current: input.current,
}) ?? input.current;
expect(native, `damage case ${index}: ${JSON.stringify(input)}`).toBe(original.damage);
});
stabCases.forEach((input, index) => {
const original = results.stab[index];
expect(original.returned, `type case ${index} returned`).toBe(true);
const native = applyTypeMatchups(matchups, {
damage: input.damage,
modifier: 10,
moveType: input.type,
userTypes: input.user,
targetTypes: input.target,
identified: input.identified,
});
expect(
[native.damage, native.modifier, Number(native.missed)],
`type case ${index}: ${JSON.stringify(input)}`,
).toEqual([original.damage, original.modifier, original.missed]);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
BASE_VIEWPORT,
MAX_VIEWPORT,
renderViewportForBox,
} from "../src/game/rendering/viewport";
describe("adaptive native viewport", () => {
it("keeps detached fixture canvases at the original resolution", () => {
expect(renderViewportForBox(0, 0)).toEqual({
...BASE_VIEWPORT,
pixelScale: 3,
});
expect(renderViewportForBox(Number.NaN, 500)).toEqual({
...BASE_VIEWPORT,
pixelScale: 3,
});
});
it("uses one integer scale on both axes and reveals additional world", () => {
const phone = renderViewportForBox(390, 720);
expect(phone).toEqual({ width: 195, height: 360, pixelScale: 2 });
const desktop = renderViewportForBox(960, 648);
expect(desktop).toEqual({ width: 320, height: 216, pixelScale: 3 });
expect(desktop.width).toBeGreaterThan(BASE_VIEWPORT.width);
expect(desktop.height).toBeGreaterThan(BASE_VIEWPORT.height);
expect(desktop.width * desktop.pixelScale).toBe(960);
expect(desktop.height * desktop.pixelScale).toBe(648);
});
it("rounds fractional boxes without materially changing their aspect", () => {
const css = { width: 713.5, height: 481.25 };
const viewport = renderViewportForBox(css.width, css.height);
expect(
Math.abs(viewport.width / viewport.height - css.width / css.height),
).toBeLessThan(0.005);
});
it.each([
[304, 400],
[844, 220],
[1920, 600],
[100_000, 100_000],
])(
"preserves the aspect of a %d x %d box while bounding buffers",
(width, height) => {
const viewport = renderViewportForBox(width, height);
expect(viewport.width).toBeLessThanOrEqual(MAX_VIEWPORT.width);
expect(viewport.height).toBeLessThanOrEqual(MAX_VIEWPORT.height);
expect(
Math.abs(viewport.width / viewport.height - width / height),
).toBeLessThan(0.03);
},
);
});
+115
View File
@@ -0,0 +1,115 @@
import type { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
/** Drive a world through scenes the way a player would: acknowledge pages,
* answer choices in order (default YES / first weekday) and wait out timed
* effects. Returns every page that was shown. */
export function settle(
world: GameWorld,
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
let lastHeld = "";
for (let tick = 0; tick < 20_000; tick++) {
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) {
shown.push(page);
// The naming screen waits for the caller unless a name is queued.
if (dialogue.choice.kind === "name") {
if (typeof answers[0] !== "string") return shown;
world.choose(answers.shift()!);
continue;
}
const answer = answers.shift();
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) {
if (page !== lastHeld) shown.push(page);
lastHeld = page;
world.tick();
} else {
shown.push(page);
world.advanceDialogue();
}
continue;
}
lastHeld = "";
if (!world.busy) {
// Scene scripts start on an idle tick.
world.tick();
if (!world.busy) return shown;
continue;
}
world.tick();
}
throw new Error("The world did not settle.");
}
const DIRECTIONS = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
/** Shortest walkable path on the current map, avoiding other warps. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
): Direction[] {
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) return node.path;
for (const [direction, dx, dy] of DIRECTIONS) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
}
/** Walk to a tile, settling any scene that a step triggers. */
export function walkTo(
world: GameWorld,
targetX: number,
targetY: number,
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
for (const direction of findPath(world, targetX, targetY)) {
if (!world.move(direction))
throw new Error(
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
);
shown.push(...settle(world, answers));
}
return shown;
}