Files
Crystal/tests/pokedex.test.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

351 lines
13 KiB
TypeScript

import { beforeAll, 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", () => {
let dex!: CrystalAssets["pokedex"];
beforeAll(() => {
dex = new CrystalAssets(new Uint8Array(readFileSync(romPath))).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 read = (path: string) => readFileSync(`${pret}/${path}`, "utf8");
let dex!: CrystalAssets["pokedex"], species!: Map<string, number>;
beforeAll(() => {
dex = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokedex;
// constants/pokemon_constants.asm numbers species from 1.
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 PAGE; PRNT waits for B on printer error.
for (let i = 0; i < 5; i++) press(world, "right");
expect(view(world).entry).toMatchObject({ action: 3 });
press(world, "a");
expect(world.dialogue?.choice?.kind).toBe("screen");
const printChoice = world.dialogue!.choice!;
if (printChoice.kind === "screen") expect(printChoice.screen.label).toContain("Printer Error 2");
world.choose("a");
expect(world.dialogue?.choice?.kind).toBe("screen");
world.choose("b");
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 });
});
});