Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
@@ -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
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user