import { beforeAll, describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { RomReader } from "../src/game/rom/reader"; import { GameWorld, migrateSave } from "../src/game/world/world"; import { toBoxMon } from "../src/game/party/storage"; import { OW } from "../src/game/world/constants"; import { rtcDay } from "../src/game/world/day-timers"; import { generatePartyMon } from "../src/game/party/pokemon"; import { ScriptRun, emptyScriptState } from "../src/game/scripts/interpreter"; import { defaultTextContext } from "../src/game/scripts/text"; import { PokemonData } from "../src/game/rom/pokemon"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe.skipIf(!existsSync(romPath))( "Goldenrod City with the local ROM", () => { let bytes: Uint8Array; beforeAll(() => { bytes = new Uint8Array(readFileSync(romPath)); }); const UNDERGROUND = "3:53", ROOFTOP = "11:18", NAME_RATER = "11:10", RADIO_TOWER_1F = "3:17", ROUTE_35_GATE = "10:14", GAME_CORNER = "11:19", POKECENTER = "11:20"; const POKE_BALL = 5, NUGGET = 0x24, HYPER_POTION = 0x10, MASTER_BALL = 1, COIN_CASE = 0x36, FLOWER_MAIL = 0x9e, SPEAROW = 21, ABRA = 63, CYNDAQUIL = 155; /** A world on `date` with a level 20 Cyndaquil caught by the player. */ const world = (date: string) => { let seed = 11; const game = new GameWorld( new CrystalAssets(bytes), () => new Date(date), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); game.state.party.push( generatePartyMon(game.assets.pokemon, { species: CYNDAQUIL, level: 20, item: 0, otId: game.playerId, ot: game.name, dvs: 0x8888, caughtData: 0, }), ); game.state.money = 20000; return game; }; /** Stand beside the object at (x, y) on `map` (across a counter if one * is in between), face it and press A. */ const talk = (game: GameWorld, map: string, x: number, y: number) => { const target = game.assets.map(map); const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], } as const; for (const [face, [dx, dy]] of Object.entries(STEP) as [ keyof typeof STEP, readonly [number, number], ][]) { const across = game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1; const px = x - dx * across, py = y - dy * across; try { game.warpTo(map, px, py); } catch { continue; } settle(game); game.move(face); settle(game); game.interact(); return; } throw new Error(`No place to talk to ${x},${y} on ${map}`); }; /** Advance to the next choice, keeping the pages shown. */ const next = (game: GameWorld, pages: string[] = []) => { for (let tick = 0; tick < 4000; tick++) { const dialogue = game.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index].replace(/\n/g, " "); if (page && pages.at(-1) !== page) pages.push(page); if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice; game.advanceDialogue(); } else if (game.busy) game.tick(); else return undefined; } throw new Error(`No choice: ${game.diagnostic}`); }; const finish = (game: GameWorld) => { const pages: string[] = []; for (let step = 0; step < 100; step++) { const choice = next(game, pages); if (!choice) { expect(game.notice, game.diagnostic).toBe(""); return pages.join(" "); } game.choose(choice.kind === "yesno" ? 0 : -1); } throw new Error( `Interaction did not finish: ${game.dialogue?.choice?.kind}`, ); }; it("runs the herb shop on Sunday with its own texts", () => { const game = world("2026-09-20T12:00:00"); talk(game, UNDERGROUND, 7, 21); const pages: string[] = []; let choice = next(game, pages); expect(choice?.kind).toBe("martlist"); if (choice?.kind !== "martlist") return; const first = choice.entries[0]; expect(game.assets.pokemon.itemName(first.item)).toBe("ENERGYPOWDER"); game.choose(0); choice = next(game, pages); expect(choice).toMatchObject({ kind: "martquantity", price: first.price, }); game.choose(2); expect(next(game, pages)?.kind).toBe("yesno"); game.choose(1); const text = finish(game); expect(game.state.bag.items).toContainEqual({ item: first.item, quantity: 2, }); expect(game.state.money).toBe(20000 - first.price * 2); expect(pages.join(" ") + text).toMatch(/bitter/i); }); it("sells one of each at the bargain shop, then closes for the day", () => { const game = world("2026-09-21T08:00:00"); talk(game, UNDERGROUND, 7, 11); let choice = next(game); if (choice?.kind !== "martlist") throw new Error("No bargain list."); expect(choice.entries[0]).toMatchObject({ item: NUGGET, price: 4500 }); game.choose(0); expect(next(game)?.kind).toBe("yesno"); game.choose(1); choice = next(game); expect(choice?.kind).toBe("martlist"); // The Nugget is sold out for the rest of this visit. game.choose(0); const pages: string[] = []; choice = next(game, pages); expect(pages.join(" ")).toMatch(/sold out/i); finish(game); expect(game.state.bag.items).toContainEqual({ item: NUGGET, quantity: 1, }); expect(game.state.money).toBe(20000 - 4500); expect( game.state.flags.has(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED), ).toBe(true); }); it("lists the rooftop sale's prices, and the second list after the Hall of Fame", () => { const game = world("2026-09-22T12:00:00"); // Run the new day's reset before enabling its sale. settle(game); const ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON = 97; game.state.flags.add(ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON); talk(game, ROOFTOP, 1, 4); let choice = next(game); if (choice?.kind !== "martlist") throw new Error("No sale list."); expect(choice.entries[0]).toMatchObject({ item: POKE_BALL, price: 150 }); finish(game); game.state.hallOfFame = true; talk(game, ROOFTOP, 1, 4); choice = next(game); if (choice?.kind !== "martlist") throw new Error("No sale list."); expect(choice.entries[0]).toMatchObject({ item: HYPER_POTION, price: 1000, }); finish(game); }); it("renames the player's own Pokémon and refuses a traded one", () => { const game = world("2026-09-22T12:00:00"); talk(game, NAME_RATER, 2, 4); expect(next(game)?.kind).toBe("yesno"); game.choose(1); expect(next(game)?.kind).toBe("menu"); game.choose(0); expect(next(game)?.kind).toBe("yesno"); game.choose(1); expect(next(game)?.kind).toBe("name"); game.choose("BLAZE"); finish(game); expect(game.state.party[0].nickname).toBe("BLAZE"); game.state.party[0].otId ^= 1; talk(game, NAME_RATER, 2, 4); game.choose(1); next(game); game.choose(0); const text = finish(game); expect(text).toMatch(/perfect/i); expect(game.state.party[0].nickname).toBe("BLAZE"); }); it("gives Kenya with Randy's MAIL", () => { const game = world("2026-09-22T12:00:00"); talk(game, ROUTE_35_GATE, 0, 4); expect(next(game)?.kind).toBe("yesno"); game.choose(1); finish(game); const kenya = game.state.party.at(-1)!; expect(kenya).toMatchObject({ species: SPEAROW, nickname: "KENYA", ot: "RANDY", otId: 1001, item: FLOWER_MAIL, }); expect(kenya.mail).toMatchObject({ message: "DARK CAVE leads\nto another road", author: "RANDY", authorId: 1001, species: SPEAROW, }); // The save keeps the gift's MAIL. const reloaded = world("2026-09-22T12:00:00"); reloaded.restore(JSON.parse(JSON.stringify(game.save()))); expect(reloaded.state.party.at(-1)!.mail?.message).toBe( "DARK CAVE leads\nto another road", ); }); it("trades coins for a prize Pokémon with the coin box on screen", () => { const game = world("2026-09-22T12:00:00"); game.state.bag.keyItems = [COIN_CASE]; game.state.coins = 250; talk(game, GAME_CORNER, 18, 2); const choice = next(game); expect(choice).toMatchObject({ kind: "menu", balance: "COIN 250" }); if (choice?.kind !== "menu") return; expect(choice.items[0]).toMatch(/^ABRA\s+100$/); game.choose(0); expect(next(game)?.kind).toBe("yesno"); game.choose(1); expect(next(game)).toMatchObject({ kind: "pokedex", view: { entry: { species: ABRA, page: 1, caught: true, actions: [] } }, }); // A save during registration replays the purchase without a partial // coin debit, Pokémon reward or Pokédex registration. expect(game.save().coins).toBe(250); expect(game.save().party).toHaveLength(1); expect(game.save().pokedex.caught).not.toContain(ABRA); game.choose("right"); expect(next(game)).toMatchObject({ kind: "pokedex", view: { entry: { page: 1 } }, }); game.choose("b"); expect(next(game)).toMatchObject({ kind: "pokedex", view: { entry: { page: 2 } }, }); game.choose("a"); finish(game); expect(game.state.coins).toBe(150); expect(game.state.party.at(-1)?.species).toBe(ABRA); expect(game.state.pokedex.caught.has(ABRA)).toBe(true); // A second prize of the same species skips registration. talk(game, GAME_CORNER, 18, 2); expect(next(game)?.kind).toBe("menu"); game.choose(0); expect(next(game)?.kind).toBe("yesno"); game.choose(1); expect(next(game)?.kind).toBe("yesno"); // nickname finish(game); expect(game.state.coins).toBe(50); expect( game.state.party.filter((mon) => mon.species === ABRA), ).toHaveLength(2); }); it.each(["party", "current box", "other box", "tied box", "worse box"])( "finds Lucky Number winners: %s", (location) => { const game = world("2026-09-22T12:00:00"); // A number drawn today stays; the show is open again. const today = Math.floor(Date.UTC(2026, 8, 22) / 86_400_000); game.state.luckyNumber = { id: 12345, day: rtcDay(today) + 1 }; game.state.party[0].otId = 12345; if (location !== "party") { const boxed = toBoxMon( generatePartyMon(game.assets.pokemon, { species: ABRA, level: 5, item: 0, otId: location === "worse box" ? 22345 : 12345, ot: "KAWA", dvs: 0, caughtData: 0, }), ); game.state.storage.boxes[location === "other box" ? 1 : 0].push( boxed, ); if (location === "current box" || location === "other box") game.state.party[0].otId = 11111; } talk(game, RADIO_TOWER_1F, 8, 6); const text = finish(game); const boxWins = location === "current box" || location === "other box"; expect(text).toContain(boxWins ? "ABRA" : "CYNDAQUIL"); expect(text).toContain(boxWins ? "PC" : "party"); expect(game.state.bag.balls).toContainEqual({ item: MASTER_BALL, quantity: 1, }); expect(game.state.flags.has(OW.ENGINE_LUCKY_NUMBER_SHOW)).toBe(true); // Until next Friday (three days from Tuesday) the show is over. expect(game.state.timers.luckyNumber).toEqual({ remaining: 3, day: rtcDay(today), }); }, ); it("migrates older saves and preserves or rejects the new daily state", () => { const game = world("2026-09-22T12:00:00"); const old = JSON.parse(JSON.stringify(game.save())); delete old.hallOfFame; delete old.timers; delete old.luckyNumber; expect(migrateSave(old, () => 0)).toMatchObject({ hallOfFame: false, timers: {}, luckyNumber: { id: 0, day: 0 }, }); game.state.hallOfFame = true; game.state.timers.luckyNumber = { remaining: 7, day: 139 }; game.state.luckyNumber = { id: 65535, day: 140 }; const save = JSON.parse(JSON.stringify(game.save())); const reloaded = world("2026-09-22T12:00:00"); reloaded.restore(save); expect(reloaded.save()).toMatchObject({ hallOfFame: true, timers: { luckyNumber: { remaining: 7, day: 139 } }, luckyNumber: { id: 65535, day: 140 }, }); for (const invalid of [ { hallOfFame: 1 }, { timers: { luckyNumber: { remaining: -1, day: 0 } } }, { timers: { luckyNumber: { remaining: 7, day: 140 } } }, { luckyNumber: { id: 65536, day: 1 } }, ]) expect(() => reloaded.restore({ ...save, ...invalid })).toThrow( "Invalid native save", ); }); it("enters Goldenrod's Pokémon Center past the GS Ball check", () => { const game = world("2026-09-22T12:00:00"); game.restore({ ...game.save(), map: POKECENTER, x: 3, y: 6 }); settle(game); game.move("down"); settle(game); expect(game.notice, game.diagnostic).toBe(""); }); it("sends a given Pokémon to the box when the party is full", () => { const reader = new RomReader(bytes); const data = new PokemonData(reader); const state = emptyScriptState(); for (let i = 0; i < 6; i++) state.party.push( generatePartyMon(data, { species: CYNDAQUIL, level: 5, item: 0, otId: 1, ot: "KAWA", dvs: 0, caughtData: 0, }), ); // givepoke EEVEE, 20 then end, in a free spot of the ROM. const rom = new Uint8Array(bytes); rom.set([0x2d, 133, 20, 0, 0, 0x91], 0x3ff0); const run = new ScriptRun(new RomReader(rom), 0x3ff0, state, { mapId: "11:2", lastTalked: 0, sceneOwner: () => undefined, timeOfDay: () => 1, clock: () => ({ weekday: 2, hours: 12, minutes: 0 }), clockDay: () => 2, text: () => defaultTextContext("KAWA"), data, player: () => ({ id: 1, gender: 0 }), random: () => 0, landmark: () => 10, facing: () => 0, position: () => ({ x: 0, y: 0 }), objectPosition: () => undefined, playerCollision: () => 0, environment: () => 0, phoneService: () => 0, facingObject: () => undefined, }); let effect = run.resume(); expect(effect).toMatchObject({ kind: "yesno" }); // a nickname? effect = run.resume(0); expect(effect?.kind).toBe("text"); if (effect?.kind === "text") expect(effect.pages.join(" ").replace(/\n/g, " ")).toContain( "EEVEE was sent to BILL's PC.", ); expect(run.resume()).toBeUndefined(); expect(run.state.party).toHaveLength(6); const box = run.state.storage.boxes[run.state.storage.current]; expect(box[0]).toMatchObject({ species: 133, level: 20, ot: "KAWA", otId: 1, }); expect(run.state.pokedex.caught.has(133)).toBe(true); }); }, );