Files
Crystal/tests/pokedex.test.ts
T
samyandClaude Opus 5.5 cdee576818
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s
Port the Pokédex list, entries and tab (#35)
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>
2026-10-03 20:19:24 +02:00

341 lines
12 KiB
TypeScript

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 });
});
});