Complete the overworld foundation
Checks / web (push) Canceled after 0s

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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 16:47:29 +02:00
1 parent 9ac111f262
commit 451464f7c6
38 files changed
+5600 -490

No files matched your search

+455
View File
@@ -0,0 +1,455 @@
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);
});
});