import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { generatePartyMon } from "../src/game/party/pokemon"; import { CreditsMachine, textboxPages } from "../src/game/scripts/league"; import { hallOfFameTiles } from "../src/game/scripts/hall-of-fame"; import { PC, RAM } from "../src/game/rom/profile"; import { tileScreenBackground } from "../src/game/rendering/tile-screen"; import { blankScreen } from "../src/game/scripts/screen"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe("tile screens scrolled like the background map", () => { // Tile 1 is solid color 3, tile 0 color 0. const tiles = new Uint8Array(2 * 16); tiles.fill(0xff, 16); const screen = () => { const s = blankScreen(tiles, "t", [[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]], ""); s.map[0] = 1; // the top-left tile return s; }; it("wraps hSCX at 256 pixels, with `fill` beyond the tilemap", () => { const s = screen(); s.scroll = { x: 256 - 8, y: 0, fill: 0 }; const { indices } = tileScreenBackground(s); // The tile moves right by one tile; the column scrolled in is fill. expect(indices[0]).toBe(0); expect(indices[8]).toBe(3); expect(indices[16]).toBe(0); }); it("gives a row its own hSCX and background tiles", () => { const s = screen(); s.map[4 * 20] = 1; s.rowScroll = [{ row: 4, x: 0xfe, tiles: Array.from({ length: 32 }, (_, x) => (x === 31 ? 1 : 0)) }]; const { indices } = tileScreenBackground(s); // Row 4 shows its 32nd tile's last two columns, then its own tiles. expect(indices[32 * 160]).toBe(3); expect(indices[32 * 160 + 2]).toBe(0); // Row 0 is not scrolled. expect(indices[0]).toBe(3); }); }); describe.skipIf(!existsSync(romPath))("the League with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const ROUTE_27 = "24:2", VICTORY_ROAD_GATE = "23:13", HALL_OF_FAME = "16:8", NEW_BARK = "24:4"; const world = () => { let seed = 21; return new GameWorld( new CrystalAssets(bytes), () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); }; const mon = (game: GameWorld, species: number, level = 50) => generatePartyMon(game.assets.pokemon, { species, level, item: 0, otId: game.playerId, ot: "KAWA", dvs: 0x8888, caughtData: 0, }); /** Run the world to the end of its scripts, pressing A on any screen * that waits, and count the ticks the credits' screens take. */ const drive = (game: GameWorld, b = false) => { const shown: string[] = []; let screens = 0, still = 0, label = ""; for (let tick = 0; tick < 60_000; tick++) { expect(game.notice, game.diagnostic).toBe(""); const dialogue = game.dialogue; if (dialogue?.choice?.kind === "screen") { screens++; const now = dialogue.choice.screen.label; if (now !== label) [label, still] = [now, 0]; if (!shown.includes(now)) shown.push(now); // B on the credits' pages only (the rating takes B as well as A). if (b && /, /.test(now) && !now.includes("play time")) game.bufferScreenButton("b"); if (++still > 60) { game.bufferScreenButton("a"); still = 0; } game.tick(); continue; } if (dialogue) { const last = dialogue.index === dialogue.pages.length - 1; if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]); if (last && dialogue.choice) game.choose(0); else if (last && dialogue.holdTicks !== undefined) game.tick(); else game.advanceDialogue(); continue; } if (!game.busy) { game.tick(); if (!game.busy) return { shown, screens }; continue; } game.tick(); } throw new Error("The world did not settle."); }; it("has Route 27's fisher stop a first step into Kanto", () => { const game = world(); game.warpTo(ROUTE_27, 18, 11); settle(game); game.restore({ ...game.save(), x: 18, y: 11, direction: "up" }); expect(game.move("up")).toBe(true); const shown = settle(game).join(" "); expect(shown).toContain("Hey!"); expect(shown).toContain("Do you know what\nyou just did?"); expect(game.state.scenes[ROUTE_27]).toBe(1); // SCENE_ROUTE27_NOOP }); it("turns back a trainer without the eight Johto badges at the Victory Road gate", () => { const game = world(); game.warpTo(VICTORY_ROAD_GATE, 10, 12); settle(game); game.restore({ ...game.save(), x: 10, y: 12, direction: "up" }); expect(game.move("up")).toBe(true); const shown = settle(game).join(" "); expect(shown).toContain("You don't have all\nthe GYM BADGES of\nJOHTO."); expect({ x: game.x, y: game.y }).toEqual({ x: 10, y: 12 }); for (let badge = 0x1b; badge < 0x1b + 8; badge++) game.state.flags.add(badge); game.restore({ ...game.save(), x: 10, y: 12, direction: "up" }); expect(game.move("up")).toBe(true); expect(settle(game).join(" ")).toContain("BADGES of JOHTO!"); game.restore({ ...game.save(), x: 10, y: 10, direction: "down" }); expect(game.move("down")).toBe(true); expect(settle(game)).toEqual([]); }); it("records the team without its Egg, keeps the play time still and wakes in New Bark", () => { const game = world(); const egg = { ...mon(game, 175, 5), egg: true }; game.state.party = [mon(game, 157, 60), egg, mon(game, 147, 30)]; game.warpTo(HALL_OF_FAME, 4, 13); let before: { hours: number; minutes: number; seconds: number } | undefined; const { shown } = (() => { // Note the play time when the ceremony's first screen appears. for (let tick = 0; tick < 2000 && !before; tick++) { if (game.dialogue?.choice?.kind === "screen") before = { ...game.gameTime }; else if (game.dialogue) game.advanceDialogue(); else game.tick(); } return drive(game); })(); expect(before).toBeDefined(); expect(shown.join(" ")).toContain("Saving record"); expect(shown).toContain("New Hall of Famer!: TYPHLOSION, TYPHLOSION, level 60, ID " + String(game.playerId).padStart(5, "0")); expect(shown.some((label) => label.startsWith("STAFF") || label.includes("CRYSTAL VERSION"))).toBe(true); expect(game.state.hallOfFame).toBe(true); expect(game.state.hallOfFameCount).toBe(1); expect(game.state.hallOfFameTeams).toHaveLength(1); expect(game.state.hallOfFameTeams[0]).toMatchObject({ win: 1, mons: [{ species: 157 }, { species: 147 }] }); expect(game.state.party.every((member) => member.egg || member.hp === member.stats[0])).toBe(true); // halloffame clears GAME_TIMER_COUNTING_F for the ceremony and credits. expect({ hours: game.gameTime.hours, minutes: game.gameTime.minutes }).toEqual({ hours: before!.hours, minutes: before!.minutes, }); expect(game.mapId).toBe(NEW_BARK); expect({ x: game.x, y: game.y }).toEqual({ x: 13, y: 6 }); // The ceremony does not play again. game.warpTo(HALL_OF_FAME, 4, 13); expect(drive(game).screens).toBe(0); }); it("caps the League wins at 200 and keeps the 30 newest teams; B shortens credits seen before", () => { const run = (count: number, b: boolean) => { const game = world(); game.state.party = [mon(game, 157, 60)]; game.state.hallOfFame = count > 0; game.state.hallOfFameCount = count; game.state.hallOfFameTeams = Array.from({ length: Math.min(30, count) }, (_, i) => ({ win: count - i, mons: [{ species: 25, otId: 1, dvs: 0, level: 5, nickname: "PIKACHU" }], })); game.warpTo(HALL_OF_FAME, 4, 13); return { game, ...drive(game, b) }; }; const capped = run(200, false); expect(capped.game.state.hallOfFameCount).toBe(200); expect(capped.game.state.hallOfFameTeams).toHaveLength(30); expect(capped.game.state.hallOfFameTeams[0]).toMatchObject({ win: 200, mons: [{ species: 157 }] }); // The first time, B does nothing; a team that has entered before can // hurry the credits along with it. expect(run(0, true).screens).toBe(run(0, false).screens); expect(run(5, true).screens).toBeLessThan(run(5, false).screens - 1000); }); describe("Credits", () => { const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined; const runCredits = (press: (frame: number) => string) => { const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false); const labels: string[] = []; for (let frame = 0; frame < 20_000; frame++) { const step = machine.step(press(frame)); let next = step.next(); while (!next.done) { machine.vblank(); next = step.next(); } if (next.value) return { frame, labels, machine }; const label = machine.screen().label; if (labels.at(-1) !== label) labels.push(label); machine.vblank(); } return { frame: -1, labels, machine }; }; it("roll from the staff to THE END and close only on A after it", () => { // A held throughout ends the credits right after CREDITS_END. const { frame, labels, machine } = runCredits(() => "a"); expect(labels).toContain("POKéMON, CRYSTAL VERSION, STAFF"); expect(labels).toContain("DIRECTOR, SATOSHI TAJIRI"); expect(labels).toContain("EXECUTIVE PRODUCER, HIROSHI YAMAUCHI"); expect(frame).toBeGreaterThan(6900); expect(machine.debug().exit).toBe(true); // THE END's 16 tiles at (6, 9) and (6, 10). expect(machine.shown.slice(9 * 20 + 6, 9 * 20 + 14)).toEqual([0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]); expect(machine.shown.slice(10 * 20 + 6, 10 * 20 + 14)).toEqual([0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f]); }); it("scroll the borders two pixels a cycle and animate the banner", () => { const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false); const scrolls = new Set(), banners = new Set(); for (let frame = 0; frame < 800; frame++) { const step = machine.step(""); let next = step.next(); while (!next.done) { machine.vblank(); next = step.next(); } const screen = machine.screen(); scrolls.add(screen.rowScroll![1].x); banners.add(screen.tileset); machine.vblank(); } expect([...scrolls].every((x) => x % 2 === 0)).toBe(true); expect(scrolls.size).toBeGreaterThan(40); expect(banners.size).toBeGreaterThan(4); }); }); it("decodes the rating's text box to tiles, keeping \"'s\" as one tile", () => { const assets = new CrystalAssets(bytes); const ram = (address: number) => address === RAM.stringBuffer3 ? "42" : address === RAM.stringBuffer4 ? "7" : ""; const pages = textboxPages(assets.reader, PC.OakPCText3, ram, "KAWA"); expect(pages).toHaveLength(2); // "42 POKéMON seen" / "7 POKéMON owned", then a para. expect(pages[0].wait).toBe(true); expect(pages[0].lines[0].slice(0, 2)).toEqual([0xfa, 0xf8]); expect(pages[0].lines[1][0]).toBe(0xfd); // "PROF.OAK's" / "Rating:" ends the text without a wait. expect(pages[1].wait).toBe(false); expect(pages[1].lines[0]).toContain(0xd4); expect(pages[1].lines[0]).toHaveLength(9); }); });