Add the Johto and Kanto atlas to the Map tab
Checks / web (push) Failing after 17s
Private ROM checks / private-rom (push) Failing after 14m39s

The Map tab draws the region's Town Map from the ROM with the player's
landmark, the visited flypoints as Fly destinations and the landmarks
whose maps are ported. Kanto opens with the Fly map's Indigo Plateau
rule. Landmark positions and flypoints are compared with the pinned
PRET source.

Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samyandClaude Opus 5.5 committed 2026-10-03 20:34:29 +02:00
1 parent cdee576818
commit 29248172bd
13 files changed
+536 -30

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
async function start(page: Page) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({
response,
body: source.replace("this.world?.beginIntro();", "").replace(
needle,
`const { pokedexFixtureWorld } = await import('/tests/pokedex-fixture.ts'); const world = pokedexFixtureWorld(assets);`,
),
});
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
test("the Map tab shows the Johto atlas with the player and open areas", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await start(page);
await page.locator("#tab-map").click();
const atlas = page.locator("#atlas");
await expect(atlas).toBeVisible();
await expect(page.locator("#atlas-title")).toHaveText("Johto");
await expect(page.locator("#atlas-regions button")).toHaveCount(0);
await expect(page.locator("#atlas-here")).toHaveText("You are in NEW BARK TOWN.");
await expect(page.locator("#atlas-map")).toHaveAttribute("aria-label", "Johto map, you are in NEW BARK TOWN");
const open = page.locator("#atlas-open li");
await expect(open.first()).toHaveText("NEW BARK TOWN");
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
await expect(page.locator("#atlas-open")).not.toContainText("AZALEA TOWN");
expect(errors).toEqual([]);
});
+132
View File
@@ -0,0 +1,132 @@
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 { generatePartyMon } from "../src/game/party/pokemon";
import { flypoints, landmarkPosition, regionAtlas } from "../src/game/world/atlas";
import { defaultTextContext } from "../src/game/scripts/text";
import { OW } from "../src/game/world/constants";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const pret = ".reference/pokecrystal";
const NEW_BARK_TOWN = "24:4",
LANDMARK_NEW_BARK_TOWN = 1,
LANDMARK_ROUTE_29 = 2,
LANDMARK_CHERRYGROVE_CITY = 3,
LANDMARK_VIOLET_CITY = 6,
LANDMARK_AZALEA_TOWN = 12,
LANDMARK_PALLET_TOWN = 47;
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
"the atlas data against the pinned PRET source",
() => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const read = (path: string) => readFileSync(`${pret}/${path}`, "utf8");
// const_def counts from 0, or from the value given.
const constants = (path: string, prefix: string) => {
const values = new Map<string, number>();
let next = 0;
for (const [, start, name] of read(path).matchAll(/^\s+(?:const_def(?: (\d+))?|const (\w+))/gm)) {
if (name === undefined) next = Number(start ?? 0);
else values.set(name, next++);
}
return new Map([...values].filter(([name]) => name.startsWith(prefix)));
};
it("places every landmark where data/maps/landmarks.asm does", () => {
const rows = [...read("data/maps/landmarks.asm").matchAll(/^\s+landmark\s+(-?\d+),\s*(-?\d+),/gm)];
expect(rows).toHaveLength(OW.LANDMARK_FAST_SHIP + 1);
rows.forEach(([, x, y], landmark) =>
expect(landmarkPosition(assets.reader, landmark), String(landmark)).toEqual({
x: Number(x),
y: Number(y),
}),
);
});
it("reads the flypoints of data/maps/flypoints.asm", () => {
const landmarks = constants("constants/landmark_constants.asm", "LANDMARK_");
const spawns = constants("constants/map_data_constants.asm", "SPAWN_");
const rows = [...read("data/maps/flypoints.asm").matchAll(/^\s+db (LANDMARK_\w+),\s*(SPAWN_\w+)/gm)].map(
([, landmark, spawn]) => ({ landmark: landmarks.get(landmark), spawn: spawns.get(spawn) }),
);
expect(rows).toHaveLength(OW.NUM_FLYPOINTS);
expect(flypoints(assets.reader)).toEqual(rows);
});
},
);
describe.skipIf(!existsSync(romPath))("the region atlas", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function fixture(visited: number[]) {
const world = new GameWorld(assets, () => new Date("2026-09-25T12:00:00"), () => 0x42);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 8, item: 0, otId: world.playerId,
ot: "GOLD", dvs: 0x9abc, caughtData: 0,
}),
];
world.restore({ ...world.save(), map: NEW_BARK_TOWN, x: 13, y: 7, direction: "down", objects: [] });
for (const spawn of visited) world.state.flags.add(assets.spawnEngineFlag(spawn)!);
return world;
}
const spawnOf = (landmark: number) =>
flypoints(assets.reader).find((point) => point.landmark === landmark)!.spawn;
it("shows Johto with the player, the visited flypoints and the open areas", () => {
const world = fixture([spawnOf(LANDMARK_NEW_BARK_TOWN), spawnOf(LANDMARK_CHERRYGROVE_CITY)]);
const atlas = world.atlas();
expect(atlas).toMatchObject({ region: "johto", regions: ["johto"], player: LANDMARK_NEW_BARK_TOWN });
expect(atlas.landmarks.map((landmark) => landmark.landmark)).toEqual(
Array.from({ length: OW.JOHTO_LANDMARK_LAST }, (_, i) => i + 1),
);
const at = (landmark: number) => atlas.landmarks[landmark - 1];
expect(at(LANDMARK_NEW_BARK_TOWN)).toMatchObject({
name: "NEW BARK TOWN",
open: true,
flypoint: { visited: true },
});
expect(at(LANDMARK_CHERRYGROVE_CITY).flypoint?.visited).toBe(true);
expect(at(LANDMARK_VIOLET_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: false, flypoint: { visited: false } });
// Kanto stays closed, so asking for it shows Johto.
expect(world.atlas("kanto").region).toBe("johto");
});
it("opens Kanto once Indigo Plateau is visited, without the player", () => {
const world = fixture([OW.SPAWN_INDIGO]);
expect(world.atlas().regions).toEqual(["johto", "kanto"]);
const kanto = world.atlas("kanto");
expect(kanto.region).toBe("kanto");
expect(kanto.player).toBeUndefined();
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: false });
expect(kanto.landmarks).toHaveLength(OW.KANTO_LANDMARK_LAST - OW.KANTO_LANDMARK + 1);
});
it("starts on Kanto when the player stands there", () => {
const atlas = regionAtlas({
reader: assets.reader,
text: () => defaultTextContext("GOLD"),
landmark: LANDMARK_PALLET_TOWN,
visited: () => false,
open: new Set(),
});
expect(atlas).toMatchObject({ region: "kanto", regions: ["johto", "kanto"], player: LANDMARK_PALLET_TOWN });
});
it("leaves the Fast Ship's landmark outside both regions", () => {
const ship = regionAtlas({
reader: assets.reader,
text: () => defaultTextContext("GOLD"),
landmark: OW.LANDMARK_FAST_SHIP,
visited: () => false,
open: new Set(),
});
expect(ship.region).toBe("johto");
expect(ship.player).toBeUndefined();
});
});