Port the Pokédex list, entries and tab (#35)
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s

The start menu's POKéDEX runs Pokedex in New mode: the list up to the
last seen Pokémon, its cursor and page moves, and DisplayDexEntry with
the category, number, footprint, and the height, weight and pages once
caught. The Pokédex tab shows the same records from the loaded game.
Order, entries and footprints are compared with the pinned PRET source.

AREA, CRY, PRNT, the option and search screens, Old and A to Z modes
and Unown mode remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samyandClaude Opus 5.5 committed 2026-10-03 20:19:24 +02:00
1 parent 03aa913a94
commit cdee576818
20 files changed
+1624 -29

No files matched your search

+4 -4
View File
@@ -71,10 +71,10 @@ test("game menu tabs expose real tools and honest planned states with keyboard n
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);
}
await page.locator("#tab-pokedex").click();
await expect(page.locator("#panel-pokedex")).toContainText("Choose a ROM");
await page.locator("#tab-link").click();
await expect(page.locator("#panel-link")).toContainText(/planned/i);
});
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
+91
View File
@@ -0,0 +1,91 @@
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 start menu's POKéDEX lists, opens entries and returns", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await start(page);
const dex = page.locator("#pokedex-screen");
await page.keyboard.press("Enter");
await page.locator("#dialogue-choice").getByRole("option", { name: /^POKéDEX/ }).click();
await expect(dex).toBeVisible();
await expect(page.locator("#native-dialogue")).toBeHidden();
const rows = dex.locator(".pokedex-list li");
await expect(rows).toHaveCount(7);
await expect(rows.first()).toHaveText(/CHIKORITA/);
await expect(rows.first()).toHaveAttribute("aria-current", "true");
await expect(rows.nth(1)).toHaveText(/-----/);
await expect(dex.locator(".pokedex-counts")).toContainText("SEEN");
await expect(dex.locator(".pokedex-pic")).toHaveAttribute("aria-label", "CHIKORITA");
// Down three rows to CYNDAQUIL, which is owned.
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowDown");
await expect(rows.nth(3)).toHaveAttribute("aria-current", "true");
await expect(rows.nth(3).locator(".pokedex-ball")).toHaveAttribute("aria-label", "Owned");
await page.keyboard.press("KeyX");
await expect(dex).toHaveAttribute("data-screen", "entry");
await expect(dex.locator(".pokedex-entry-details")).toContainText("FIRE MOUSE");
await expect(dex.locator(".pokedex-entry-details")).toContainText(`HT 1'08"`);
await expect(dex.locator(".pokedex-page")).toHaveText("P.1");
await page.keyboard.press("KeyX");
await expect(dex.locator(".pokedex-page")).toHaveText("P.2");
// Up steps back to the previous seen Pokémon, which is not caught.
await page.keyboard.press("ArrowUp");
await expect(dex.locator(".pokedex-entry-details")).toContainText("CHIKORITA");
await expect(dex.locator(".pokedex-entry-details")).toContainText(`HT ?'??"`);
await expect(dex.locator(".pokedex-text")).toHaveText("");
// B returns to the list, then to the start menu.
await page.keyboard.press("KeyZ");
await expect(dex).toHaveAttribute("data-screen", "list");
await page.keyboard.press("KeyZ");
await expect(dex).toBeHidden();
await expect(page.locator("#dialogue-choice").getByRole("option", { name: /^POKéDEX/ })).toBeVisible();
expect(errors).toEqual([]);
});
test("the Pokédex tab shows seen and owned records from the game", async ({ page }) => {
await start(page);
await page.locator("#tab-pokedex").click();
const panel = page.locator("#panel-pokedex");
await expect(panel.locator("h2")).toHaveText("SEEN 3 · OWN 1");
const list = panel.locator(".pokedex-tab-list li");
// PIDGEY is tenth in New mode.
await expect(list).toHaveCount(10);
await expect(list.nth(1)).toHaveText(/-----/);
await panel.getByRole("button", { name: /CYNDAQUIL/ }).click();
await expect(panel.locator(".pokedex-tab-entry")).toContainText("FIRE MOUSE");
await expect(panel.locator(".pokedex-tab-entry .pokedex-text")).toHaveCount(2);
await panel.getByRole("button", { name: /PIDGEY/ }).click();
await expect(panel.locator(".pokedex-tab-entry")).toContainText("TINY BIRD");
await expect(panel.locator(".pokedex-tab-entry")).toContainText("Catch this Pokémon");
await expect(panel.locator(".pokedex-tab-entry .pokedex-text")).toHaveCount(0);
});
+21
View File
@@ -0,0 +1,21 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { GameWorld } from "../src/game/world/world";
/** New Bark Town with a Cyndaquil, the Pokédex, and three Pokémon seen:
* CHIKORITA, CYNDAQUIL (caught) and PIDGEY. */
export function pokedexFixtureWorld(assets: CrystalAssets) {
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0x42);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 8, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x9abc, caughtData: 0,
}),
];
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set([152, 155, 16]);
world.state.pokedex.caught = new Set([155]);
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
return world;
}
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { dexHeight, dexWeight } from "../src/game/rom/pokedex";
import { GameWorld } from "../src/game/world/world";
import { generatePartyMon } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import type { PokedexView } from "../src/game/scripts/pokedex";
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",
BULBASAUR = 1,
PIDGEY = 16,
CHIKORITA = 152,
BAYLEEF = 153,
CYNDAQUIL = 155,
QUILAVA = 156,
TOTODILE = 158,
SENTRET = 161,
HOOTHOOT = 163;
describe("the Pokédex number formats", () => {
it("prints height and weight over the ROM templates", () => {
const height = `HT ?'??"`,
weight = "WT ???lb";
expect(dexHeight(height, 204)).toBe(`HT 2'04"`);
expect(dexHeight(height, 8)).toBe(`HT 0'08"`);
expect(dexHeight(height, 1701)).toBe(`HT 17'01"`);
expect(dexWeight(weight, 150)).toBe("WT 15.0lb");
expect(dexWeight(weight, 1)).toBe("WT 0.1lb");
expect(dexWeight(weight, 4760)).toBe("WT 476.0lb");
// DisplayDexEntry skips a zero word and leaves the question marks.
expect(dexHeight(height, 0)).toBe(height);
expect(dexWeight(weight, 0)).toBe(weight);
});
});
describe.skipIf(!existsSync(romPath))("the Pokédex data", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const dex = assets.pokedex;
it("reads its strings and the New mode order", () => {
expect(dex.dexString("String_SEEN")).toBe("SEEN");
expect(dex.dexString("String_OWN")).toBe("OWN");
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.Height")).toBe(`HT ?'??"`);
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.Weight")).toBe("WT ???lb");
expect(dex.dexString("Pokedex_DrawDexEntryScreenBG.MenuItems").trim()).toBe(
"PAGE AREA CRY PRNT",
);
expect(dex.notSeenName()).toBe("-----");
const order = dex.newOrder();
expect(order.slice(0, 3)).toEqual([CHIKORITA, BAYLEEF, 154]);
expect([...order].sort((a, b) => a - b)).toEqual(
Array.from({ length: 251 }, (_, i) => i + 1),
);
});
it("reads an entry's category, measurements and pages", () => {
const entry = dex.entry(BULBASAUR);
expect(entry).toMatchObject({ category: "SEED", height: 204, weight: 150 });
expect(entry.pages[0].split("\n")).toHaveLength(3);
expect(entry.pages[1].split("\n")).toHaveLength(3);
for (let species = 1; species <= 251; species++) dex.entry(species);
});
it("decodes the question mark picture and its palette", () => {
expect(dex.questionMarkPic()).toHaveLength(49 * 16);
// RGB 11, 23, 00 from gfx/pokedex/question_mark.pal.
expect(dex.questionMarkPalette()[0]).toEqual([90, 189, 0]);
});
});
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/pokemon/dex_order_new.asm`))(
"the Pokédex data against the pinned PRET source",
() => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const dex = assets.pokedex;
const read = (path: string) => readFileSync(`${pret}/${path}`, "utf8");
// constants/pokemon_constants.asm numbers species from 1.
const species = new Map(
[...read("constants/pokemon_constants.asm").matchAll(/^\s+const (\w+)/gm)].map(
([, name], index) => [name, index + 1],
),
);
it("matches NewPokedexOrder", () => {
const order = [...read("data/pokemon/dex_order_new.asm").matchAll(/^\s+db (\w+)/gm)].map(
([, name]) => species.get(name),
);
expect(dex.newOrder()).toEqual(order);
});
it("matches every entry in data/pokemon/dex_entries", () => {
const files = new Map(
[...read("data/pokemon/dex_entries.asm").matchAll(/^(\w+)::\s+INCLUDE "([^"]+)"/gm)].map(
([, label, file]) => [label, file],
),
);
const labels = [
...read("data/pokemon/dex_entry_pointers.asm").matchAll(/^\s+dw (\w+)/gm),
].map(([, label]) => label);
expect(labels).toHaveLength(251);
labels.forEach((label, index) => {
const source = read(files.get(label)!);
const [, category] = /db "([^"]*)@"/.exec(source)!;
const [, height, weight] = /dw (\d+), (\d+)/.exec(source)!;
const pages: string[][] = [[]];
for (const [, command, text] of source.matchAll(/^\s+(db|next|page)\s+"([^"]*)"/gm)) {
if (text.endsWith("@") && command === "db" && !pages[0].length && text === `${category}@`)
continue;
if (command === "page") pages.push([]);
pages.at(-1)!.push(text.replace(/@$/, "").replaceAll("#", "POKé"));
}
expect(dex.entry(index + 1)).toEqual({
category,
height: Number(height),
weight: Number(weight),
pages: pages.map((lines) => lines.join("\n")),
});
});
});
it("matches each species' footprint", () => {
const tops = [
...read("gfx/footprints.asm").matchAll(/^INCBIN "([^"]+)",\s+footprint_top/gm),
].map(([, file]) => file);
for (let index = 0; index < 251; index++) {
const bytes = readFileSync(`${pret}/${tops[index]}`);
expect(dex.footprint(index + 1), tops[index]).toEqual(
new Uint8Array(bytes.subarray(0, 32)),
);
}
});
},
);
describe.skipIf(!existsSync(romPath))("the Pokédex screen", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function fixture(seen: number[], caught: number[]) {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
world.state.party = [
generatePartyMon(world.assets.pokemon, {
species: CYNDAQUIL,
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: [],
});
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.pokedex.seen = new Set(seen);
world.state.pokedex.caught = new Set(caught);
return world;
}
function view(world: GameWorld): PokedexView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pokedex")
throw new Error(`Expected the Pokédex, got ${choice?.kind}`);
return choice.view;
}
function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
const index = choice.items.indexOf("POKéDEX");
expect(index).toBe(0);
world.choose(index);
return view(world);
}
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
}
const names = (dex: PokedexView) => dex.rows.map((row) => row.name);
it("lists the New mode order up to the last seen Pokémon", () => {
const world = fixture([CHIKORITA, CYNDAQUIL, QUILAVA, PIDGEY], [CYNDAQUIL]);
const dex = open(world);
expect(dex).toMatchObject({
screen: "list",
seen: 4,
own: 1,
cursor: 0,
scroll: 0,
labels: { seen: "SEEN", own: "OWN" },
picture: CHIKORITA,
});
// PIDGEY is tenth in New mode, so the list ends there.
expect(dex.end).toBe(10);
expect(names(dex)).toEqual([
"CHIKORITA",
"-----",
"-----",
"CYNDAQUIL",
"QUILAVA",
"-----",
"-----",
]);
expect(dex.rows.map((row) => row.caught)).toEqual([
false, false, false, true, false, false, false,
]);
// The cursor stops at the last seen Pokémon; an unseen one shows "?".
press(world, "down");
expect(view(world)).toMatchObject({ cursor: 1 });
expect(view(world).picture).toBeUndefined();
for (let i = 0; i < 12; i++) press(world, "down");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 3, picture: PIDGEY });
// Left goes up a page, at most to the top; right goes back down.
press(world, "left");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 0 });
press(world, "right");
expect(view(world)).toMatchObject({ cursor: 6, scroll: 3 });
});
it("ignores A on an unseen Pokémon and opens a seen one's entry", () => {
const world = fixture([CHIKORITA, CYNDAQUIL], [CYNDAQUIL]);
open(world);
press(world, "down");
press(world, "a");
expect(view(world).screen).toBe("list");
press(world, "down");
press(world, "down");
press(world, "a");
const dex = view(world);
expect(dex.screen).toBe("entry");
expect(dex.picture).toBe(CYNDAQUIL);
expect(dex.entry).toMatchObject({
species: CYNDAQUIL,
name: "CYNDAQUIL",
category: "FIRE MOUSE",
number: "155",
height: `HT 1'08"`,
weight: "WT 17.0lb",
caught: true,
page: 1,
actions: ["PAGE", "AREA", "CRY", "PRNT"],
action: 0,
});
expect(dex.entry!.text).toBe(world.assets.pokedex.entry(CYNDAQUIL).pages[0]);
});
it("hides measurements and text until the Pokémon is caught", () => {
const world = fixture([CHIKORITA], []);
open(world);
press(world, "a");
const entry = view(world).entry!;
expect(entry).toMatchObject({
name: "CHIKORITA",
category: "LEAF",
number: "152",
height: `HT ?'??"`,
weight: "WT ???lb",
caught: false,
});
expect(entry.text).toBeUndefined();
});
it("turns pages, moves the arrow and steps through seen entries", () => {
const world = fixture(
[CHIKORITA, CYNDAQUIL, TOTODILE, SENTRET],
[CHIKORITA, CYNDAQUIL, TOTODILE, SENTRET],
);
open(world);
press(world, "a");
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 2 });
expect(view(world).entry!.text).toBe(world.assets.pokedex.entry(CHIKORITA).pages[1]);
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 });
// The arrow stops at PRNT and at PAGE; AREA, CRY and PRNT do nothing.
for (let i = 0; i < 5; i++) press(world, "right");
expect(view(world).entry).toMatchObject({ action: 3 });
press(world, "a");
expect(view(world).entry).toMatchObject({ action: 3, page: 1 });
press(world, "left");
expect(view(world).entry).toMatchObject({ action: 2 });
press(world, "a");
press(world, "a");
expect(view(world).entry).toMatchObject({ page: 1 });
// Down skips unseen Pokémon and starts the next entry on page 1 with
// the arrow on PAGE.
press(world, "down");
expect(view(world).entry).toMatchObject({ species: CYNDAQUIL, page: 1, action: 0 });
press(world, "down");
expect(view(world).entry).toMatchObject({ species: TOTODILE });
press(world, "down");
expect(view(world).entry).toMatchObject({ species: SENTRET });
// SENTRET is 19th in New mode.
expect(view(world)).toMatchObject({ cursor: 6, scroll: 12 });
// Past the last seen Pokémon nothing moves.
press(world, "down");
expect(view(world).entry).toMatchObject({ species: SENTRET });
press(world, "up");
expect(view(world).entry).toMatchObject({ species: TOTODILE });
// B returns to the list where the entries left the cursor.
press(world, "b");
expect(view(world)).toMatchObject({ screen: "list", cursor: 0, scroll: 6 });
});
it("leaves to the start menu and reopens on the last entry", () => {
const world = fixture(
[CHIKORITA, CYNDAQUIL, PIDGEY, HOOTHOOT],
[CYNDAQUIL],
);
open(world);
for (let i = 0; i < 9; i++) press(world, "down");
expect(view(world)).toMatchObject({ picture: PIDGEY });
press(world, "a");
press(world, "b");
press(world, "b");
expect(world.dialogue?.choice?.kind).toBe("menu");
// Pokedex_InitCursorPosition scrolls as far as the last page allows
// (HOOTHOOT is 15th), then moves the cursor to PIDGEY, 10th.
world.choose(0);
expect(view(world)).toMatchObject({ screen: "list", picture: PIDGEY, scroll: 8, cursor: 1 });
// The draft changes nothing, and closing the menu commits the cursor.
press(world, "b");
press(world, "b");
expect(world.dialogue).toBeUndefined();
expect(open(world)).toMatchObject({ picture: PIDGEY });
});
});