import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { addHallOfFameEntry, getHallOfFameParty, validHallOfFame, type HallOfFameRecords, } from "../src/game/party/hall-of-fame"; import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon"; import { CrystalAssets } from "../src/game/rom/assets"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { hallOfFamePC } from "../src/game/scripts/hall-of-fame"; import { GameWorld } from "../src/game/world/world"; const minimal = { species: 25, otId: 12345, dvs: 0xaaaa, level: 42, nickname: "SPARK", } as PartyMon; describe("Hall of Fame records", () => { it("copies just the historical fields and skips Eggs, retaining order", () => { const team = getHallOfFameParty( [ minimal, { ...minimal, species: 175, egg: true }, { ...minimal, species: 133 }, ], 1, ); expect(team).toEqual({ win: 1, mons: [minimal, { ...minimal, species: 133 }], }); team.mons[0].nickname = "OLD"; expect(minimal.nickname).toBe("SPARK"); expect(getHallOfFameParty([{ ...minimal, egg: true }], 2)).toEqual({ win: 2, mons: [], }); }); it("shifts out the oldest team at 30 and caps wins at 200 independently of the League flag", () => { const state = { hallOfFameCount: 0, hallOfFameTeams: [], hallOfFame: false, } as HallOfFameRecords & { hallOfFame: boolean }; for (let i = 0; i < 35; i++) addHallOfFameEntry(state, [{ ...minimal, otId: i }]); expect(state.hallOfFameTeams).toHaveLength(30); expect(state.hallOfFameTeams.map((team) => team.win)).toEqual( Array.from({ length: 30 }, (_, i) => 35 - i), ); expect(state.hallOfFameTeams[29].mons[0].otId).toBe(5); for (let i = 35; i < 205; i++) addHallOfFameEntry(state, [minimal]); expect(state.hallOfFameCount).toBe(200); expect(state.hallOfFameTeams.slice(0, 6).map((team) => team.win)).toEqual( Array(6).fill(200), ); expect(state.hallOfFame).toBe(false); expect(validHallOfFame(state.hallOfFameCount, state.hallOfFameTeams)).toBe( true, ); }); it("rejects malformed records and count/history contradictions", () => { expect(validHallOfFame(0, [])).toBe(true); for (const [count, teams] of [ [null, []], [201, []], [1, []], [0, [getHallOfFameParty([minimal], 1)]], [1, null], [1, [{ win: 1, mons: null }]], [1, [{ win: 1, mons: [{ ...minimal, species: 253 }] }]], [1, [{ win: 1, mons: Array(7).fill(minimal) }]], ] as const) expect(validHallOfFame(count, teams)).toBe(false); }); }); const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))("Hall of Fame with the local ROM", () => { const assets = existsSync(romPath) ? new CrystalAssets(new Uint8Array(readFileSync(romPath))) : undefined; const fixture = () => { const world = new GameWorld(assets!); world.state.party = [ generatePartyMon(assets!.pokemon, { species: 25, level: 42, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xaaaa, caughtData: 0, }), ]; world.restore({ ...world.save(), map: "26:5", x: 9, y: 2, direction: "up", objects: [], }); return world; }; const untilChoice = (world: GameWorld) => { for (let i = 0; i < 300; i++) { if (world.dialogue?.choice) return world.dialogue.choice; if (world.dialogue) world.advanceDialogue(); else if (world.busy) world.tick(); else return; } throw Error("PC did not settle"); }; it("uses the count and Pokédex for the PC menu, then routes screen buttons back to the PC", () => { const world = fixture(); world.state.flags.add(ENGINE_FLAGS.pokedex); world.state.hallOfFame = true; world.interact(); let menu = untilChoice(world); expect(menu?.kind).toBe("menu"); if (menu?.kind !== "menu") return; expect(menu.items).not.toContain("HALL OF FAME"); world.choose(-1); untilChoice(world); world.state.hallOfFame = false; addHallOfFameEntry(world.state, world.state.party); world.interact(); menu = untilChoice(world); if (menu?.kind !== "menu") throw Error("No PC menu"); expect(menu.items).toContain("HALL OF FAME"); world.choose(menu.items.indexOf("HALL OF FAME")); const page = untilChoice(world); expect(page?.kind).toBe("screen"); if (page?.kind === "screen") expect(page.screen.label).toContain("PIKACHU"); world.choose("b"); expect(untilChoice(world)?.kind).toBe("menu"); world.choose(-1); untilChoice(world); world.state.flags.delete(ENGINE_FLAGS.pokedex); world.interact(); menu = untilChoice(world); if (menu?.kind !== "menu") throw Error("No PC menu"); expect(menu.items).not.toContain("HALL OF FAME"); }); it("browses newest first, ignores directions, advances with A and skips teams with Start", () => { const teams = [ getHallOfFameParty([minimal, { ...minimal, nickname: "SECOND" }], 3), { win: 2, mons: [] }, getHallOfFameParty([{ ...minimal, nickname: "OLDER" }], 1), ]; const run = hallOfFamePC(assets!.pokemon, teams, 0); const label = (result: ReturnType) => !result.done && result.value.kind === "screen" ? result.value.screen.label : ""; expect(label(run.next())).toContain("SPARK"); expect(label(run.next("down"))).toContain("SPARK"); expect(label(run.next("a"))).toContain("SECOND"); expect(label(run.next("start"))).toContain("OLDER"); expect(run.next("a").done).toBe(true); const cancel = hallOfFamePC(assets!.pokemon, teams, 0); cancel.next(); expect(cancel.next("b").done).toBe(true); }); it("round-trips records, migrates by absence, and rejects explicit damage", () => { const world = fixture(); addHallOfFameEntry(world.state, world.state.party); const saved = JSON.parse(JSON.stringify(world.save())); const loaded = fixture(); loaded.restore(saved); expect(loaded.state.hallOfFameTeams).toEqual(world.state.hallOfFameTeams); saved.hallOfFameTeams[0].mons[0].nickname = "CHANGED"; expect(loaded.state.hallOfFameTeams[0].mons[0].nickname).toBe("PIKACHU"); const legacy = JSON.parse(JSON.stringify(world.save())); delete legacy.hallOfFameCount; delete legacy.hallOfFameTeams; loaded.restore(legacy); expect(loaded.state.hallOfFameTeams).toEqual([]); expect(loaded.state.hallOfFameCount).toBe(0); for (const mutate of [ (s: any) => (s.hallOfFameCount = null), (s: any) => (s.hallOfFameTeams = null), (s: any) => (s.hallOfFameTeams[0].mons[0].level = 101), ]) { const bad = JSON.parse(JSON.stringify(world.save())); mutate(bad); expect(() => loaded.restore(bad)).toThrow(/Invalid native save/); } }); });