Files
Crystal/tests/overworld-rom.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

456 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, walkTo } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const EVENT_HOLE_IN_BURNED_TOWER = 818;
/** A world on maps that normal play does not enable yet. */
function fixture(maps: string[]) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || maps.includes(id);
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0);
world.state = emptyScriptState();
return { assets, world };
}
function hold(world: GameWorld, direction: Direction | undefined, ticks: number) {
for (let tick = 0; tick < ticks; tick++) world.tick(direction);
}
describe.skipIf(!existsSync(romPath))("overworld foundation with the local ROM", () => {
it("changeblock in Burned Tower's TILES callback closes the hole until the event is set", () => {
const { assets, world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2; // SCENE_BURNEDTOWER1F_NOOP
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.permission(assets.collision(world.map, 10, 9))).toBe(0);
expect(assets.collision(world.map, 10, 9)).not.toBe(0x60);
// The ROM's own block data still has the hole; only the working copy changed.
expect(assets.collision(assets.map(BURNED_TOWER_1F), 10, 9)).toBe(0x60);
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.collision(world.map, 10, 9)).toBe(0x60);
});
it("falls through a pit with FallIntoMapScript's skyfall and earthquake", () => {
const { world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2;
world.state.scenes[BURNED_TOWER_B1F] = 1;
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
// Turn, then walk up onto the hole.
hold(world, "up", 4);
hold(world, "up", 8);
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_1F, 10, 9]);
// The arrival tick falls through; the next starts the rest of the script.
hold(world, undefined, 2);
expect(world.notice, world.diagnostic).toBe("");
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_B1F, 10, 9]);
expect(world.activity).toBe("scene");
// Invisible above the screen at first, then falling into view.
expect(world.poses().some((pose) => pose.id === 0)).toBe(false);
let shook = false;
for (let tick = 0; tick < 200 && world.busy; tick++) {
world.tick();
if (world.screenShake) shook = true;
}
expect(world.busy).toBe(false);
expect(shook).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ x: 160, y: 144 });
});
it("rides the Goldenrod Dept. Store elevator through its menu and -1 warps", () => {
const [store1F, store3F, elevator] = ["11:11", "11:13", "11:17"];
const { world } = fixture([store1F, store3F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
expect([world.mapId, world.x, world.y]).toEqual([elevator, 1, 3]);
// The arrival warp is numbered -1, so the entrance became the backup.
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
walkTo(world, 3, 1);
world.move("up");
world.interact();
const menu = world.dialogue!;
const shown = settle(world, [3]);
expect(shown.join("\n")).toContain("Now on: 1F");
expect(menu.choice).toMatchObject({ kind: "menu" });
expect((menu.choice as { items: string[] }).items).toEqual([
"B1F", "1F", "2F", "3F", "4F", "5F", "6F",
]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store3F, warp: 3 });
walkTo(world, 2, 3);
world.move("down");
settle(world);
// Arrival at warp 3 (2, 0), then the doorway's forced step down.
expect([world.mapId, world.x, world.y]).toEqual([store3F, 2, 1]);
expect(world.notice, world.diagnostic).toBe("");
});
it("keeps the floor when the elevator menu is cancelled or the same floor is chosen", () => {
const [store1F, elevator] = ["11:11", "11:17"];
const { world } = fixture([store1F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
walkTo(world, 3, 1);
world.move("up");
for (const answer of [-1, 1]) {
world.interact();
settle(world, [answer]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
}
});
describe("wild encounters (CanEncounterWildMon and ChooseWildEncounter)", () => {
const UNION_CAVE_1F = "3:37";
const cave = () => {
const { assets, world } = fixture([UNION_CAVE_1F]);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 20, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
}),
];
return { assets, world };
};
/** Walk back and forth until a battle starts or the steps run out. */
const pace = (world: GameWorld, steps: number, directions: Direction[]) => {
for (let step = 0; step < steps && !world.battle; step++) {
world.move(directions[step % directions.length]);
for (let tick = 0; tick < 4 && !world.battle; tick++) world.tick();
}
return world.battle?.engine;
};
it("starts on any cave floor after the five-step cooldown", () => {
const { world } = cave();
world.warpTo(UNION_CAVE_1F, 9, 6);
const battle = pace(world, 12, ["right", "left"]);
expect(world.notice, world.diagnostic).toBe("");
expect(battle).toBeDefined();
expect(world.tilesWalked).toBe(5);
});
it("stays off with wildoff", () => {
const { world } = cave();
world.state.noWildEncounters = true;
world.warpTo(UNION_CAVE_1F, 9, 6);
expect(pace(world, 12, ["right", "left"])).toBeUndefined();
});
it("uses the water table's rate and slots while surfing", () => {
const { assets, world } = cave();
const sea = assets.waterWildMons(UNION_CAVE_1F)!;
expect(sea).toBeDefined();
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map: UNION_CAVE_1F, x: 7, y: 4, direction: "right", objects: [] });
const battle = pace(world, 12, ["right", "left"]);
expect(battle).toBeDefined();
// Random is always 0: the first slot, with no level variety.
expect(battle!.enemy.mon).toMatchObject({
species: sea.slots[0].species,
level: sea.slots[0].level,
});
});
});
it("draws overworld Pokémon from their party menu icons (GetMonSprite)", () => {
const { assets } = fixture([]);
const unown = assets.sprite(0x80)!; // SPRITE_UNOWN
expect(unown.walking).toBe(true);
expect(unown.palette).toBe(0);
expect(unown.graphics).toHaveLength(8 * 16);
expect(assets.sprite(0x80 + 35)).toBeUndefined();
expect(assets.sprite(103)).toBeUndefined();
});
it("surfing onto a whirlpool spins the player back with Script_ForcedMovement", () => {
const { assets, world } = fixture([]);
// Find a whirlpool with water on the side a surfer comes from.
let found: { map: string; x: number; y: number } | undefined;
for (let group = 1; group <= 26 && !found; group++)
for (let number = 1; number <= 40 && !found; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1 && !found; y++)
for (let x = 0; x < map.width && !found; x++)
if (
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
assets.permission(assets.collision(map, x, y + 2)) === 1 &&
!map.objects.some((o) => o.x === x && (o.y === y + 1 || o.y === y + 2))
)
found = { map: map.id, x, y };
}
expect(found).toBeDefined();
const { map, x, y } = found!;
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === map;
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map, x, y: y + 2, direction: "up", objects: [] });
hold(world, "up", 8);
expect([world.x, world.y]).toEqual([x, y + 1]);
hold(world, "up", 9);
expect([world.x, world.y]).toEqual([x, y]);
expect(world.activity).toBe("scene");
const directions = new Set<string>();
for (let tick = 0; tick < 120 && world.busy; tick++) {
world.tick();
directions.add(world.direction);
}
expect(world.notice, world.diagnostic).toBe("");
// .MovementData_up: spin, turn_in DOWN back off the whirlpool, spin,
// then face down.
expect([world.x, world.y, world.direction]).toEqual([x, y + 1, "down"]);
expect(directions.size).toBe(4);
});
});
describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
const TOTODILE = 158;
const partyWith = (assets: CrystalAssets, moves: number[], species = TOTODILE) => {
const mon = generatePartyMon(assets.pokemon, {
species, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "SWIMMY";
return [mon];
};
/** Find a tile on some map, searching every group. */
const find = (
assets: CrystalAssets,
test: (map: ReturnType<CrystalAssets["map"]>, x: number, y: number) => boolean,
) => {
for (let group = 1; group <= 26; group++)
for (let number = 1; number <= 60; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++) if (test(map, x, y)) return { map: map.id, x, y };
}
throw new Error("No suitable tile.");
};
it("surfs from New Bark Town's shore through AskSurfScript and lands again", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]); // SURF
world.state.flags.add(30); // ENGINE_FOGBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSURF!");
expect(world.state.playerState).toBe(4);
expect([world.x, world.y]).toEqual([18, 7]);
// The surfer sprite (SPRITE_SURF) replaces Chris.
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(0x53);
// Water saves and reloads with the surfer on it.
world.restore(world.save());
expect(world.state.playerState).toBe(4);
world.move("left");
expect([world.x, world.y, world.state.playerState]).toEqual([17, 7, 0]);
});
it("does not offer Surf without the Fog Badge or a Pokémon that knows it", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
expect(world.dialogue).toBeUndefined();
world.state.flags.add(30);
world.state.party = partyWith(assets, [0x0f]);
world.interact();
expect(world.dialogue).toBeUndefined();
});
it("cuts a tree with AskCutScript until the map loads again", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]); // CUT
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).toBe(0);
world.move("up");
expect([world.x, world.y]).toEqual([spot.x, spot.y]);
world.warpTo(spot.map, spot.x, spot.y + 1);
expect(world.map.blocks).toEqual(before);
});
it("explains a Cut tree without the move", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x21]);
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown.length).toBeGreaterThan(0);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).not.toBe(0);
});
it("uses Strength through AskStrengthScript and pushes a boulder", () => {
const { assets, world } = fixture([]);
// A Strength boulder object with floor below it and two floor tiles above.
const spot = find(assets, (map, x, y) => {
const boulder = map.objects.find(
(o) => o.x === x && o.y === y && o.movement === 25 && o.eventFlag === 0xffff,
);
return Boolean(
boulder &&
map.callbacks.length === 0 &&
[y + 1, y - 1, y - 2].every(
(row) =>
assets.permission(assets.collision(map, x, row)) === 0 &&
!map.objects.some((o) => o !== boulder && o.x === x && o.y === row),
),
);
});
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x46]); // STRENGTH
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
for (let tick = 0; tick < 20; tick++) world.tick("up");
const boulder = world.objects().find((o) => o.movement === 25 && o.x === spot.x);
expect(boulder?.y).toBe(spot.y - 1);
// Talking to it again: "Boulders may now be moved!"
settle(world);
});
it("rides the Bicycle on a route and gets off indoors", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x21]);
world.state.bag.keyItems.push(7); // BICYCLE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
expect(world.useBicycle()).toBe(true);
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("got on the\nBICYCLE.");
expect(world.state.playerState).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(2); // SPRITE_CHRIS_BIKE
// Turn left (four ticks), then four ticks per tile on the bike.
for (let tick = 0; tick < 4; tick++) world.tick("left");
world.tick("left");
expect(world.x).toBe(12);
for (let tick = 0; tick < 4; tick++) world.tick("left");
expect(world.x).toBe(11);
// Entering the house (indoors) gets the player off (CheckUpdatePlayerSprite).
settle(world);
walkTo(world, 13, 5);
expect(world.mapId).toBe("24:6");
expect(world.state.playerState).toBe(0);
expect(world.useBicycle()).toBe(false);
});
it("offers Whirlpool and Waterfall only with the move and badge", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
const whirlpool = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
!map.objects.some((o) => o.x === x && o.y === y + 1),
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === whirlpool.map;
world.state.playerState = 4;
world.restore({ ...world.save(), map: whirlpool.map, x: whirlpool.x, y: whirlpool.y + 1, direction: "up", objects: [] });
world.interact();
const mighty = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(mighty.length).toBeGreaterThan(0);
world.state.party = partyWith(assets, [0x39, 250]); // SURF, WHIRLPOOL
world.state.flags.add(33); // ENGINE_GLACIERBADGE
const before = world.map.blocks.slice();
world.interact();
const used = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
it("drops a boulder pushed into Ice Path B1F's hole through its stone table script", () => {
const ICE_PATH_B1F = "3:62";
const EVENT_BOULDER_IN_ICE_PATH_2A = 1806;
const { assets, world } = fixture([ICE_PATH_B1F]);
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
world.state.party = [mon];
world.warpTo(ICE_PATH_B1F, 4, 5);
world.state.flags.add(24); // ENGINE_STRENGTH_ACTIVE
world.state.events.add(EVENT_BOULDER_IN_ICE_PATH_2A);
world.restore({
...world.save(),
x: 4,
y: 5,
direction: "down",
objects: [{ id: 3, x: 4, y: 6, direction: "down" }],
});
world.state.flags.add(24);
expect(world.objects().some((o) => o.id === 3)).toBe(true);
world.tick("down");
const shown: string[] = [];
for (let tick = 0; tick < 40 && world.activity !== "scene"; tick++) world.tick();
expect(world.activity).toBe("scene");
shown.push(...settle(world));
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/fell\s+through/i);
expect(world.objects().some((o) => o.id === 3)).toBe(false);
expect(world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
});
});