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