import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld, migrateSave } from "../src/game/world/world"; import { generatePartyMon } from "../src/game/party/pokemon"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { OW } from "../src/game/world/constants"; import { advanceGameTime, emptyGameTime, type GameTime, } from "../src/game/world/game-time"; import type { TrainerCardView } from "../src/game/scripts/trainer-card"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const NEW_BARK_TOWN = "24:4", CYNDAQUIL = 155; describe("the game timer", () => { const frames = (time: GameTime, count: number) => { for (let i = 0; i < count; i++) advanceGameTime(time); return time; }; it("counts 60 frames to the second and carries into hours", () => { expect(frames(emptyGameTime(), 59)).toEqual({ ...emptyGameTime(), frames: 59 }); expect(frames(emptyGameTime(), 60)).toEqual({ ...emptyGameTime(), seconds: 1 }); const time = { hours: 4, minutes: 59, seconds: 59, frames: 59, capped: false }; expect(frames(time, 1)).toEqual({ ...emptyGameTime(), hours: 5 }); }); it("caps at 999:59:59.00 when it would reach 1000 hours", () => { const time = { hours: 999, minutes: 59, seconds: 59, frames: 58, capped: false }; frames(time, 1); expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 59, capped: false }); frames(time, 1); expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true }); frames(time, 5000); expect(time).toEqual({ hours: 999, minutes: 59, seconds: 59, frames: 0, capped: true }); }); }); describe.skipIf(!existsSync(romPath))("the trainer card", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); function fixture(avatar: "chris" | "kris" = "chris") { 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: "CHRIS", dvs: 0x9abc, caughtData: 0, }), ]; world.state.money = 3456; world.restore({ ...world.save(), map: NEW_BARK_TOWN, x: 13, y: 7, direction: "down", objects: [], name: avatar === "kris" ? "KRIS" : "GOLD", avatar, playerId: 417, }); return world; } 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."); // StartMenu.StatusString is . const index = choice.items.indexOf(world.name); expect(index).toBeGreaterThan(-1); world.choose(index); return view(world); } function view(world: GameWorld): TrainerCardView { const choice = world.dialogue?.choice; if (choice?.kind !== "trainercard") throw new Error(`Expected the trainer card, got ${choice?.kind}`); expect(world.trainerCard).toBe(choice.view); return choice.view; } 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); } it("shows the name, ID, money and play time with ROM labels", () => { const world = fixture(); world.gameTime = { hours: 12, minutes: 7, seconds: 3, frames: 0, capped: false }; const card = open(world); expect(card).toMatchObject({ page: 1, female: false, name: "GOLD", id: "00417", money: 3456, time: { hours: 12, minutes: 7 }, colon: false, badges: 0, labels: { name: "NAME/", money: "MONEY", pokedex: "POKéDEX", playTime: "PLAY TIME", badges: "BADGES▶", }, }); // Without the Pokédex its line is cleared. expect(card.pokedex).toBeUndefined(); }); it("counts caught Pokémon once the Pokédex is owned", () => { const world = fixture("kris"); world.state.flags.add(ENGINE_FLAGS.pokedex); world.state.pokedex.seen = new Set([1, 4, 7, 155]); world.state.pokedex.caught = new Set([155, 7]); const card = open(world); expect(card.pokedex).toBe(2); expect(card.female).toBe(true); expect(card.name).toBe("KRIS"); }); it("turns pages and leaves with the original buttons", () => { const world = fixture(); open(world); // Left, up and down do nothing on page 1; A or right turns the page. for (const button of ["left", "up", "down"] as const) { press(world, button); expect(view(world).page).toBe(1); } press(world, "right"); expect(view(world).page).toBe(2); // Right does nothing on page 2; left turns back. press(world, "right"); expect(view(world).page).toBe(2); press(world, "left"); expect(view(world).page).toBe(1); press(world, "a"); expect(view(world).page).toBe(2); // A on page 2 leaves, back to the start menu. press(world, "a"); expect(world.trainerCard).toBeUndefined(); expect(world.dialogue?.choice?.kind).toBe("menu"); // B leaves from page 1. world.choose((world.dialogue!.choice as { items: string[] }).items.indexOf("GOLD")); expect(view(world).page).toBe(1); press(world, "b"); expect(world.trainerCard).toBeUndefined(); expect(world.dialogue?.choice?.kind).toBe("menu"); settle(world, [-1]); expect(world.busy).toBe(false); expect(world.diagnostic).toBe(""); }); it("keeps play time running, blinks the colon and animates owned badges", () => { const world = fixture(); world.state.flags.add(OW.ENGINE_ZEPHYRBADGE); world.state.flags.add(OW.ENGINE_MINERALBADGE); world.gameTime = { hours: 0, minutes: 0, seconds: 59, frames: 40, capped: false }; const card = open(world); expect(card.badges).toBe(0b10001); // The colon appears after 32 frames (16 ticks) and goes after 64. for (let tick = 0; tick < 15; tick++) world.tick(); expect(view(world).colon).toBe(false); world.tick(); expect(view(world)).toMatchObject({ colon: true, time: { hours: 0, minutes: 1 } }); for (let tick = 0; tick < 16; tick++) world.tick(); expect(view(world).colon).toBe(false); // Page 2 restarts the timing; the badges step every eight frames. press(world, "right"); expect(view(world).badgeFrame).toBe(0); const steps = []; for (let tick = 0; tick < 36; tick++) { world.tick(); steps.push(view(world).badgeFrame); } expect(steps.filter((_, i) => i % 4 === 3)).toEqual([1, 2, 3, 4, 5, 6, 7, 0, 1]); expect(view(world).colon).toBe(false); // Two frames per tick were counted throughout: 68 ticks from 0:59.40. expect(world.gameTime).toMatchObject({ minutes: 1, seconds: 1, frames: 56 }); }); it("shows no badge animation without badges", () => { const world = fixture(); open(world); press(world, "right"); for (let tick = 0; tick < 20; tick++) world.tick(); expect(view(world)).toMatchObject({ badges: 0, badgeFrame: 0 }); }); it("does not count during the introduction", () => { const world = fixture(); world.gameTime = { hours: 3, minutes: 0, seconds: 0, frames: 0, capped: false }; world.beginIntro(); expect(world.gameTime).toEqual(emptyGameTime()); for (let tick = 0; tick < 100; tick++) world.tick(); expect(world.gameTime).toEqual(emptyGameTime()); }); it("saves the play time, including time spent in a scene", () => { const world = fixture(); world.gameTime = { hours: 7, minutes: 30, seconds: 0, frames: 0, capped: false }; open(world); for (let tick = 0; tick < 30; tick++) world.tick(); const save = world.save(); expect(save.gameTime).toEqual({ hours: 7, minutes: 30, seconds: 1, frames: 0, capped: false }); const other = fixture(); other.restore(save); expect(other.gameTime).toEqual(save.gameTime); // Older saves start from zero; invalid times are refused. const { gameTime: _, ...old } = save; expect(migrateSave(old, () => 0)?.gameTime).toEqual(emptyGameTime()); expect(() => other.restore({ ...save, gameTime: { ...save.gameTime, minutes: 60 } }), ).toThrow(/Invalid native save/); }); });