- 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>
351 lines
13 KiB
TypeScript
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 });
|
|
});
|
|
});
|