- Decode evolution lists from the ROM; all 251 match the pinned PRET source. - Compare CalcMonStats and CalcExpAtLevel against the original routines in the reference emulator. - Add a continuous opening playthrough from the intro to the catching tutorial, reloading a JSON save after every stage. - Port player switching in battle: the PKMN menu, forced choices after a faint, Shift mode's offer and double faints, with the original texts. - Port HP and status healing items in battle through the ROM's ItemEffects, HealingHPAmounts and StatusHealingActions tables. - Add the battle party menu and the Items pocket to the UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1002 lines
38 KiB
TypeScript
1002 lines
38 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import { RomReader } from "../src/game/rom/reader";
|
||
import { decompress } from "../src/game/rom/lz";
|
||
import { CrystalAssets } from "../src/game/rom/assets";
|
||
import { GameWorld } from "../src/game/world/world";
|
||
import { NATIVE_MAPS, SYMBOLS } from "../src/game/rom/profile";
|
||
import {
|
||
ScriptRun,
|
||
emptyScriptState,
|
||
runScript,
|
||
} from "../src/game/scripts/interpreter";
|
||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||
import type { Evolution } from "../src/game/rom/pokemon";
|
||
import { readMovement } from "../src/game/scripts/movement";
|
||
import { defaultTextContext, readText } from "../src/game/scripts/text";
|
||
import { settle, walkTo } from "./world-driver";
|
||
import { drawTile } from "../src/game/rendering/pixels";
|
||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||
import { VECTORS, type Direction } from "../src/game/world/types";
|
||
|
||
describe("native ROM formats", () => {
|
||
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
|
||
const reader = new RomReader(new Uint8Array(0xc000));
|
||
expect(reader.address(2, 0x4321)).toBe(0x8321);
|
||
expect(reader.address(2, 0x1234)).toBe(0x1234);
|
||
expect(() => reader.address(3, 0x4000)).toThrow();
|
||
expect(() => reader.word(0xbfff)).toThrow();
|
||
expect(() => reader.slice(-1, 1)).toThrow();
|
||
});
|
||
it("decodes all LZ commands, long runs, overlapping copies and termination", () => {
|
||
const decode = (input: number[]) => [
|
||
...decompress(new RomReader(Uint8Array.from(input)), 0),
|
||
];
|
||
expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([
|
||
1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0,
|
||
]);
|
||
expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]);
|
||
expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]);
|
||
expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]);
|
||
expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0));
|
||
expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference");
|
||
expect(() =>
|
||
decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10),
|
||
).toThrow("oversized");
|
||
expect(() => decode([0x20, 4])).toThrow();
|
||
});
|
||
it("decodes planar pixels with transparency and flips", () => {
|
||
const pixels = new Uint8ClampedArray(8 * 8 * 4);
|
||
const tile = new Uint8Array(16);
|
||
tile[0] = 0x80;
|
||
tile[1] = 0x40;
|
||
const palette = [
|
||
[0, 0, 0],
|
||
[255, 0, 0],
|
||
[0, 255, 0],
|
||
[0, 0, 255],
|
||
] as const;
|
||
drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, {
|
||
transparent: true,
|
||
flipX: true,
|
||
});
|
||
expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]);
|
||
expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]);
|
||
expect(pixels[3]).toBe(0);
|
||
});
|
||
it("aborts unknown script commands without committing earlier event writes", () => {
|
||
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
|
||
const state = emptyScriptState();
|
||
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
|
||
expect(state.events.size).toBe(0);
|
||
});
|
||
it("bounds looping scripts", () => {
|
||
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
|
||
expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow(
|
||
"budget",
|
||
);
|
||
});
|
||
it("resumes scripts after choices and writes only to a private draft", () => {
|
||
const bytes = new Uint8Array(0x100);
|
||
// writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end
|
||
bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0);
|
||
bytes.set([0x33, 7, 0, 0x91], 0x10);
|
||
// "HI" then the player's name through text_ram, then done.
|
||
bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40);
|
||
const state = emptyScriptState();
|
||
const host = {
|
||
mapId: "1:1",
|
||
lastTalked: 0,
|
||
sceneOwner: () => undefined,
|
||
timeMask: () => 2,
|
||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||
clockDay: () => 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, 0x5a, 0x47,
|
||
// turn_step, turn_in, set_sliding, step_dig 16, skyfall,
|
||
// step_shake $54, fish_got_bite, step_bump, step_wait_end 5
|
||
0x05, 0x26, 0x39, 0x4f, 16, 0x4e, 0x55, 0x54, 0x51, 0x50, 0x48, 5,
|
||
]),
|
||
);
|
||
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");
|
||
expect(readMovement(reader, 11)).toEqual([
|
||
{ kind: "turn-step", direction: "up", ticks: 3 },
|
||
{ kind: "step", direction: "left", ticks: 8, animate: true, spin: true },
|
||
{ kind: "sliding", sliding: true },
|
||
{ kind: "pose", pose: "spin", ticks: 16 },
|
||
{ kind: "pose", pose: "skyfall", ticks: 31 },
|
||
{ kind: "shake", displacement: 0x54 },
|
||
{ kind: "pose", pose: "fish-bite", ticks: 8 },
|
||
{ kind: "bump", ticks: 1 },
|
||
{ kind: "wait-end", ticks: 5 },
|
||
]);
|
||
});
|
||
it("reads RAM-backed names and string buffers only from native values", () => {
|
||
// text_ram wStringBuffer4, " ", text_buffer 0, done
|
||
const bytes = Uint8Array.from([
|
||
0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50,
|
||
]);
|
||
const context = {
|
||
...defaultTextContext("KAWA"),
|
||
strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" },
|
||
};
|
||
expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM");
|
||
expect(() =>
|
||
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
|
||
).toThrow("no native value");
|
||
});
|
||
});
|
||
|
||
const romPath =
|
||
process.env.CRYSTAL_ROM_PATH ??
|
||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||
describe.skipIf(!existsSync(romPath))(
|
||
"native engine with local user ROM (never checked in)",
|
||
() => {
|
||
const load = () => {
|
||
const bytes = readFileSync(romPath);
|
||
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
|
||
CRYSTAL_ROM.sha1,
|
||
);
|
||
return new CrystalAssets(new Uint8Array(bytes));
|
||
};
|
||
it("decodes every supported map, tileset, event table and sprite", () => {
|
||
const assets = load();
|
||
for (const id of Object.keys(NATIVE_MAPS)) {
|
||
const map = assets.map(id),
|
||
tileset = assets.tileset(map);
|
||
expect(map.blocks.length).toBe((map.width * map.height) / 4);
|
||
expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600);
|
||
expect(assets.palettes(map, 1).length).toBe(8);
|
||
for (const warp of map.warps) {
|
||
expect(warp.x).toBeLessThan(map.width);
|
||
expect(warp.y).toBeLessThan(map.height);
|
||
if (warp.targetMap in NATIVE_MAPS)
|
||
expect(
|
||
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
|
||
).toBeDefined();
|
||
}
|
||
}
|
||
expect(assets.sprite(1)?.graphics.length).toBe(384);
|
||
expect(assets.sprite(0x60)?.graphics.length).toBe(384);
|
||
expect(assets.map("24:7").warps[0]).toEqual({
|
||
x: 7,
|
||
y: 0,
|
||
targetMap: "24:6",
|
||
targetWarp: 3,
|
||
});
|
||
});
|
||
it.skipIf(
|
||
!existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"),
|
||
)(
|
||
"matches decompressed graphics against the locally rebuilt PRET reference",
|
||
() => {
|
||
const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp";
|
||
const assets = load();
|
||
expect(assets.tileset(assets.map("24:7")).graphics).toEqual(
|
||
new Uint8Array(readFileSync(path)),
|
||
);
|
||
},
|
||
);
|
||
it("walks with the original timing: eight overworld ticks per tile", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
world.restore({ ...world.save(), x: 3, y: 2, direction: "right" });
|
||
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(true);
|
||
for (let y = 0; y < town.height; y++)
|
||
expect(assets.visibleBlock(town, -1, y)).toBe(
|
||
assets.block(route, west.x, y + west.y),
|
||
);
|
||
expect(assets.visibleBlock(town, -1, -1)).toBe(town.border);
|
||
expect(() => assets.map("24:99")).toThrow("Unknown map");
|
||
expect(() => assets.map("27:1")).toThrow("Unknown map");
|
||
});
|
||
it("plays Mom's opening scene with native choices, then walks outside", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
// The bedroom's NEWMAP callback ran the original initialization script.
|
||
expect(world.state.events.size).toBeGreaterThan(100);
|
||
walkTo(world, 7, 0);
|
||
expect(world.mapId).toBe("24:6");
|
||
expect([world.x, world.y]).toEqual([9, 1]);
|
||
const flags = world.state.flags.size;
|
||
// Monday, confirm, DST yes, confirm time, "know how to use the phone".
|
||
const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]);
|
||
expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF.");
|
||
expect(pages).toContain("CHRIS received\nPOKéGEAR.");
|
||
expect(pages).toContain("What day is it?");
|
||
expect(pages).toContain("MONDAY, is it?");
|
||
expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true);
|
||
expect(pages).toContain("Don't you just\nturn the POKéGEAR");
|
||
const save = world.save();
|
||
expect(save.scenes).toEqual({ "24:6": 1 });
|
||
expect(save.phone).toEqual([1]);
|
||
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
|
||
expect(save.clock).toEqual({ minuteOffset: 0, 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"]);
|
||
// Back home, SPRITEMOVEDATA_SPINRANDOM_SLOW spins her again at once.
|
||
const teacher = world.objects().find((object) => object.id === 2)!;
|
||
expect([teacher.x, teacher.y]).toEqual([6, 8]);
|
||
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");
|
||
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);
|
||
// The first half of the jump occupies the tile jumped over.
|
||
expect([world.x, world.y]).toEqual([x + dx, y + dy]);
|
||
expect(
|
||
world.poses().find((pose) => pose.id === 0)?.lift,
|
||
).toBeLessThan(0);
|
||
settle(world);
|
||
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
|
||
expect(world.notice).toBe("");
|
||
checked.add(collision);
|
||
}
|
||
}
|
||
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
|
||
});
|
||
it("gives the Route 29 item ball's Potion through FindItemInBallScript, hides the ball and commits atomically", () => {
|
||
const assets = load();
|
||
const route = assets.map("24:3");
|
||
const world = new GameWorld(assets);
|
||
const ball = route.objects.find((object) => object.kind === 1)!; // OBJECTTYPE_ITEMBALL
|
||
// The tile west of the ball is the only walkable approach.
|
||
world.restore({
|
||
...world.save(),
|
||
map: route.id,
|
||
x: ball.x - 1,
|
||
y: ball.y,
|
||
direction: "right",
|
||
objects: [],
|
||
});
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
true,
|
||
);
|
||
world.interact();
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS found\nPOTION!",
|
||
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
|
||
]);
|
||
expect(world.diagnostic).toBe("");
|
||
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||
expect(world.state.events.has(ball.eventFlag)).toBe(true);
|
||
// The ball disappears for the rest of the session...
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
false,
|
||
);
|
||
// ...and the pickup survives save()/restore().
|
||
const saved = world.save();
|
||
const restored = new GameWorld(assets);
|
||
restored.restore(saved);
|
||
expect(restored.save()).toEqual(saved);
|
||
expect(restored.objects().some((object) => object.id === ball.id)).toBe(
|
||
false,
|
||
);
|
||
});
|
||
it("shows the original no-room text and changes nothing when the item pocket is full", () => {
|
||
const assets = load();
|
||
const route = assets.map("24:3");
|
||
const world = new GameWorld(assets);
|
||
const ball = route.objects.find((object) => object.kind === 1)!;
|
||
const full = world.save();
|
||
// Twenty full POTION stacks: PutItemInPocket finds no room for another.
|
||
full.bag.items = Array.from({ length: 20 }, () => ({
|
||
item: 18,
|
||
quantity: 99,
|
||
}));
|
||
world.restore({
|
||
...full,
|
||
map: route.id,
|
||
x: ball.x - 1,
|
||
y: ball.y,
|
||
direction: "right",
|
||
objects: [],
|
||
});
|
||
world.interact();
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS found\nPOTION!",
|
||
"But CHRIS can't\ncarry any more\nitems.",
|
||
]);
|
||
expect(world.diagnostic).toBe("");
|
||
expect(world.state.bag).toEqual(full.bag);
|
||
expect(world.state.events.has(ball.eventFlag)).toBe(false);
|
||
expect(world.objects().some((object) => object.id === ball.id)).toBe(
|
||
true,
|
||
);
|
||
});
|
||
it("refuses connections into unported maps and imports version 1 saves", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
const route = world.assets.map("26:1");
|
||
const edge = Array.from({ length: route.width }, (_, x) => x).find(
|
||
(x) =>
|
||
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
|
||
)!;
|
||
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
|
||
expect(world.move("up")).toBe(false);
|
||
expect(world.notice).toBe("Route 31 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("decodes evolution lists, including stat and happiness triggers", () => {
|
||
const data = load().pokemon;
|
||
expect(data.evolutions(155)).toEqual([
|
||
{ method: "level", level: 14, species: 156 },
|
||
]);
|
||
expect(data.evolutions(133).map((e) => e.method)).toEqual([
|
||
"item",
|
||
"item",
|
||
"item",
|
||
"happiness",
|
||
"happiness",
|
||
]);
|
||
expect(data.evolutions(133)[4]).toEqual({
|
||
method: "happiness",
|
||
time: "nite",
|
||
species: 197,
|
||
});
|
||
expect(data.evolutions(61)).toContainEqual({
|
||
method: "trade",
|
||
item: 82, // KING'S ROCK
|
||
species: 186,
|
||
});
|
||
expect(data.evolutions(64)).toEqual([
|
||
{ method: "trade", item: null, species: 65 },
|
||
]);
|
||
expect(data.evolutions(236)).toEqual([
|
||
{ method: "stat", level: 20, attack: "lt", species: 107 },
|
||
{ method: "stat", level: 20, attack: "gt", species: 106 },
|
||
{ method: "stat", level: 20, attack: "eq", species: 237 },
|
||
]);
|
||
expect(data.evolutions(251)).toEqual([]);
|
||
// The four-byte stat records must not shift the learnset.
|
||
expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 });
|
||
});
|
||
it.skipIf(
|
||
!existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"),
|
||
)("matches every evolution list against the pinned PRET source", () => {
|
||
const data = load().pokemon;
|
||
const root = ".reference/pokecrystal";
|
||
const constants = (file: string, first: number) => {
|
||
const ids = new Map<string, number>();
|
||
let value = first;
|
||
for (const line of readFileSync(`${root}/${file}`, "utf8").split(
|
||
"\n",
|
||
)) {
|
||
if (/^DEF NUM_/.test(line)) break;
|
||
const name = /^\s*const (\w+)/.exec(line)?.[1];
|
||
if (name) ids.set(name, value++);
|
||
}
|
||
return ids;
|
||
};
|
||
const species = constants("constants/pokemon_constants.asm", 1),
|
||
items = constants("constants/item_constants.asm", 0);
|
||
const id = (map: Map<string, number>, name: string) => {
|
||
const value = map.get(name);
|
||
if (value === undefined) throw new Error(`Unknown ${name}`);
|
||
return value;
|
||
};
|
||
const lists: Evolution[][] = [];
|
||
let current: Evolution[] | null = null;
|
||
for (const line of readFileSync(
|
||
`${root}/data/pokemon/evos_attacks.asm`,
|
||
"utf8",
|
||
).split("\n")) {
|
||
if (/^\w+EvosAttacks:/.test(line)) lists.push((current = []));
|
||
const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line);
|
||
if (!args || !current) {
|
||
if (/^\s*db 0 ; no more evolutions/.test(line)) current = null;
|
||
continue;
|
||
}
|
||
const [a, b, c] = args[2].split(",").map((s) => s.trim());
|
||
if (args[1] === "EVOLVE_LEVEL")
|
||
current.push({ method: "level", level: +a, species: id(species, b) });
|
||
else if (args[1] === "EVOLVE_ITEM")
|
||
current.push({
|
||
method: "item",
|
||
item: id(items, a),
|
||
species: id(species, b),
|
||
});
|
||
else if (args[1] === "EVOLVE_TRADE")
|
||
current.push({
|
||
method: "trade",
|
||
item: a === "-1" ? null : id(items, a),
|
||
species: id(species, b),
|
||
});
|
||
else if (args[1] === "EVOLVE_HAPPINESS")
|
||
current.push({
|
||
method: "happiness",
|
||
time: a.slice(3).toLowerCase() as "anytime",
|
||
species: id(species, b),
|
||
});
|
||
else
|
||
current.push({
|
||
method: "stat",
|
||
level: +a,
|
||
attack: b.slice(4, 6).toLowerCase() as "gt",
|
||
species: id(species, c),
|
||
});
|
||
}
|
||
expect(lists).toHaveLength(251);
|
||
lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list));
|
||
});
|
||
it("gives the chosen starter and the aide's Potion, then saves them", () => {
|
||
let seed = 7;
|
||
const random = () => (seed = (seed * 73 + 41) & 255);
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
random,
|
||
);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "24:5",
|
||
x: 4,
|
||
y: 3,
|
||
scenes: { "24:6": 1, "24:5": 1 },
|
||
});
|
||
walkTo(world, 6, 4);
|
||
expect(world.move("up")).toBe(false);
|
||
world.interact();
|
||
// pokepic shows the ROM's front picture and waits for a button.
|
||
expect(world.picture).toBe(155);
|
||
expect(world.dialogue?.pages).toEqual([""]);
|
||
world.advanceDialogue();
|
||
expect(world.picture).toBeUndefined();
|
||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||
// Take it, then give it a nickname through the naming screen.
|
||
const pages = settle(world, [1, 1]);
|
||
expect(pages).toContain("CHRIS received\nCYNDAQUIL!");
|
||
expect(world.dialogue?.choice).toMatchObject({
|
||
kind: "name",
|
||
current: "CYNDAQUIL",
|
||
});
|
||
expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]);
|
||
// Characters the naming screen cannot enter keep the prompt open.
|
||
world.choose("BAD~NAME");
|
||
expect(world.dialogue?.choice?.kind).toBe("name");
|
||
world.choose("BLAZE");
|
||
expect(settle(world)).toContain("CHRIS got ELM's\nphone number.");
|
||
const save = world.save();
|
||
expect(save.party).toHaveLength(1);
|
||
expect(save.party[0]).toMatchObject({
|
||
species: 155,
|
||
nickname: "BLAZE",
|
||
ot: "CHRIS",
|
||
otId: world.playerId,
|
||
level: 5,
|
||
exp: 135,
|
||
item: 173,
|
||
moves: [33, 43, 0, 0],
|
||
pp: [35, 30, 0, 0],
|
||
happiness: 70,
|
||
// Day, level 5; male player in New Bark Town.
|
||
caughtData: 0x8501,
|
||
});
|
||
expect(save.party[0].hp).toBe(save.party[0].stats[0]);
|
||
expect(save.pokedex).toEqual({ seen: [155], caught: [155] });
|
||
expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 });
|
||
expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM
|
||
// The other balls now only describe themselves.
|
||
walkTo(world, 7, 4);
|
||
world.move("up");
|
||
world.interact();
|
||
expect(settle(world)).toHaveLength(1);
|
||
expect(world.save().party).toHaveLength(1);
|
||
walkTo(world, 4, 7);
|
||
expect(world.move("down")).toBe(true);
|
||
expect(settle(world)).toEqual([
|
||
"CHRIS, I want\nyou to have this\nfor your errand.",
|
||
"CHRIS received\nPOTION.",
|
||
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
|
||
"There are only two\nof us, so we're\nalways busy.",
|
||
]);
|
||
const final = world.save();
|
||
expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||
expect(final.scenes["24:5"]).toBe(2);
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T12:00:00"),
|
||
);
|
||
restored.restore(final);
|
||
expect(restored.save()).toEqual(final);
|
||
// Stored stats must match the ROM's formula for the stored DVs.
|
||
const tampered = structuredClone(final);
|
||
tampered.party[0].stats[1] += 1;
|
||
expect(() => restored.restore(tampered)).toThrow("Invalid");
|
||
const potion = structuredClone(final);
|
||
potion.bag.keyItems = [18];
|
||
expect(() => restored.restore(potion)).toThrow("Invalid");
|
||
expect(restored.save()).toEqual(final);
|
||
});
|
||
it("reads the bedroom radio through native event and text interpreters", () => {
|
||
const world = new GameWorld(load());
|
||
world.restore({ ...world.save(), x: 3, y: 2, direction: "up" });
|
||
const previousEvents = world.state.events.size;
|
||
world.interact();
|
||
expect(world.diagnostic).toBe("");
|
||
// Each radio line holds for 45 ticks (pause 45), ignoring buttons.
|
||
expect(world.dialogue?.holdTicks).toBe(45);
|
||
world.advanceDialogue();
|
||
expect(world.dialogue?.holdTicks).toBe(45);
|
||
expect(world.state.events.size).toBe(previousEvents);
|
||
expect(settle(world)).toHaveLength(4);
|
||
expect(world.state.events.size).toBe(previousEvents + 1);
|
||
world.interact();
|
||
expect(settle(world)).toHaveLength(1);
|
||
});
|
||
it("heals a damaged, statused party through the Cherrygrove nurse at every time of day", () => {
|
||
const greetings = {
|
||
morning: ["Good morning!\nWelcome to our\nPOKéMON CENTER."],
|
||
day: ["Hello!\nWelcome to our\nPOKéMON CENTER."],
|
||
night: [
|
||
"Good evening!\nYou're out late.",
|
||
"Welcome to our\nPOKéMON CENTER.",
|
||
],
|
||
};
|
||
for (const [label, hour] of [
|
||
["morning", 8],
|
||
["day", 13],
|
||
["night", 21],
|
||
] as const) {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`),
|
||
);
|
||
const mon = generatePartyMon(assets.pokemon, {
|
||
species: 155,
|
||
level: 10,
|
||
item: 0,
|
||
otId: world.playerId,
|
||
ot: "CHRIS",
|
||
dvs: 0x823b,
|
||
caughtData: 0,
|
||
});
|
||
mon.moves = [45, 0, 0, 0]; // GROWL: base PP 40
|
||
mon.pp = [(3 << 6) | 2, 0, 0, 0]; // three PP Ups, nearly spent
|
||
mon.hp = 1;
|
||
mon.status = 0x08; // paralysis
|
||
world.state.party.push(mon);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:5",
|
||
x: 3,
|
||
y: 3,
|
||
direction: "up",
|
||
});
|
||
// The counter at (3,2) lets interact() reach the nurse behind it.
|
||
world.interact();
|
||
expect(world.dialogue?.pages).toEqual(greetings[label]);
|
||
const pages = settle(world, [1]);
|
||
expect(pages).toContain("Your POKéMON are\nfully healed.");
|
||
const healed = world.save().party[0];
|
||
expect(healed.hp).toBe(healed.stats[0]);
|
||
expect(healed.status).toBe(0);
|
||
// ComputeMaxPP clamps each PP Up's bonus to 7, not 8: 40 + 3*7 = 61.
|
||
expect(healed.pp).toEqual([(3 << 6) | 61, 0, 0, 0]);
|
||
expect(world.diagnostic).toBe("");
|
||
}
|
||
});
|
||
it("leaves the party unchanged when healing is declined", () => {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
const mon = generatePartyMon(assets.pokemon, {
|
||
species: 155,
|
||
level: 10,
|
||
item: 0,
|
||
otId: world.playerId,
|
||
ot: "CHRIS",
|
||
dvs: 0x823b,
|
||
caughtData: 0,
|
||
});
|
||
mon.hp = 1;
|
||
mon.status = 0x08;
|
||
world.state.party.push(mon);
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:5",
|
||
x: 3,
|
||
y: 3,
|
||
direction: "up",
|
||
});
|
||
world.interact();
|
||
const before = structuredClone(world.save().party);
|
||
const pages = settle(world, [0]);
|
||
expect(pages.at(-1)).toBe("We hope to see you\nagain.");
|
||
expect(pages).not.toContain("Your POKéMON are\nfully healed.");
|
||
expect(world.save().party).toEqual(before);
|
||
expect(world.diagnostic).toBe("");
|
||
});
|
||
it("records the whiteout spawn by walking into the Cherrygrove Pokécenter, and keeps it through save/restore", () => {
|
||
const assets = load();
|
||
const world = new GameWorld(
|
||
assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
expect(world.save().lastSpawnMap).toBeNull();
|
||
world.restore({
|
||
...world.save(),
|
||
map: "26:3",
|
||
x: 29,
|
||
y: 4,
|
||
direction: "up",
|
||
});
|
||
// The Center's door is an automatic warp; EnterMapWarp's SetSpawn
|
||
// records the outdoor map (Cherrygrove City) as the new blackout spot.
|
||
expect(world.move("up")).toBe(true);
|
||
expect(world.mapId).toBe("26:5");
|
||
expect([world.x, world.y]).toEqual([3, 7]);
|
||
expect(world.save().lastSpawnMap).toBe("26:3");
|
||
const restored = new GameWorld(
|
||
world.assets,
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
restored.restore(world.save());
|
||
expect(restored.save()).toEqual(world.save());
|
||
});
|
||
it("honors the recorded spawn for the whiteout warp, and defaults to the player's house", () => {
|
||
const assets = load();
|
||
const host = {
|
||
mapId: "1:1",
|
||
lastTalked: 0,
|
||
sceneOwner: () => undefined,
|
||
timeOfDay: () => 1,
|
||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||
clockDay: () => 0,
|
||
text: () => defaultTextContext("CHRIS"),
|
||
data: assets.pokemon,
|
||
player: () => ({ id: 0, gender: 0 as const }),
|
||
random: () => 0,
|
||
landmark: () => 0,
|
||
facing: () => 0,
|
||
position: () => ({ x: 0, y: 0 }),
|
||
objectPosition: () => undefined,
|
||
};
|
||
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
|
||
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
|
||
const whiteoutWarp = (lastSpawnMap: string | null) => {
|
||
const state = emptyScriptState();
|
||
state.lastSpawnMap = lastSpawnMap;
|
||
const run = new ScriptRun(
|
||
assets.reader,
|
||
SYMBOLS.battleWhiteoutScript,
|
||
state,
|
||
host,
|
||
);
|
||
let effect = run.resume();
|
||
while (effect && effect.kind !== "warp") effect = run.resume();
|
||
return effect;
|
||
};
|
||
expect(whiteoutWarp("26:3")).toEqual({
|
||
kind: "warp",
|
||
map: "26:3",
|
||
x: 29,
|
||
y: 4,
|
||
});
|
||
// No recorded Center, or one the spawn table no longer lists: SPAWN_HOME.
|
||
expect(whiteoutWarp(null)).toEqual({
|
||
kind: "warp",
|
||
map: "24:7",
|
||
x: 3,
|
||
y: 3,
|
||
});
|
||
expect(whiteoutWarp("1:1")).toEqual({
|
||
kind: "warp",
|
||
map: "24:7",
|
||
x: 3,
|
||
y: 3,
|
||
});
|
||
});
|
||
it("migrates a save without the spawn field to the player's house default", () => {
|
||
const world = new GameWorld(
|
||
load(),
|
||
() => new Date("2026-09-22T13:00:00"),
|
||
);
|
||
const { lastSpawnMap, ...withoutSpawn } = world.save();
|
||
void lastSpawnMap;
|
||
expect(() => world.restore(withoutSpawn)).not.toThrow();
|
||
expect(world.save().lastSpawnMap).toBeNull();
|
||
});
|
||
},
|
||
);
|