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 { caughtData, generatePartyMon } from "../src/game/party/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))("Olivine and Cianwood side content with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const ROUTE_39_BARN = "1:10", MANIAS_HOUSE = "22:4", PHOTO_STUDIO = "22:8", SEERS_HOUSE = "22:10"; const SHUCKLE = 213, BERRY = 0xad, MANIA_OT_ID = 518, LANDMARK_NEW_BARK_TOWN = 1, LANDMARK_GIFT = 0x7e, LANDMARK_EVENT = 0x7f, DAY = 1; // DAY_F const EVENT_GOT_SHUCKIE = 0x45, EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 0x46, ENGINE_GOT_SHUCKIE_TODAY = 0x55, EVENT_HEALED_MOOMOO = 0x3d, EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 0x3f; /** A world whose clock `now` moves. */ const world = (date = "2026-09-22T12:00:00") => { let seed = 13; const clock = { now: new Date(date) }; const game = new GameWorld( new CrystalAssets(bytes), () => clock.now, () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); return { game, clock }; }; const mon = ( game: GameWorld, species: number, level: number, extra: Partial> = {}, ) => Object.assign( generatePartyMon(game.assets.pokemon, { species, level, item: 0, otId: game.playerId, ot: "KAWA", dvs: 0x8888, caughtData: 0, }), extra, ); /** Enter `map`, stand at (x, y) facing `face`, press A and answer. */ const talk = ( game: GameWorld, map: string, x: number, y: number, face: "up" | "down" | "left" | "right", answers: number[] = [], ) => { game.warpTo(map, x, y); settle(game); game.restore({ ...game.save(), x, y, direction: face }); game.interact(); const shown = settle(game, answers).join(" "); expect(game.notice, game.diagnostic).toBe(""); return shown; }; /** Mania stands at (2, 4); talk to him from below. */ const mania = (game: GameWorld, answers: number[] = []) => talk(game, MANIAS_HOUSE, 2, 5, "up", answers); it("nurses Route 39's Miltank back to health with seven Berries", () => { const { game } = world(); game.state.party = [mon(game, 155, 20)]; // MooMoo (3, 3) only asks for Berries once the farmer has explained. const moomoo = (answers: number[] = []) => talk(game, ROUTE_39_BARN, 3, 4, "up", answers); const before = moomoo(); expect(before).toContain("MILTANK: …Moo…"); expect(before).not.toContain("Give a BERRY"); game.state.events.add(EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO); expect(moomoo()).toContain("has no\nBERRIES…"); game.state.bag.items = [{ item: BERRY, quantity: 7 }]; expect(moomoo([0])).toContain("wouldn't\ngive a BERRY."); const fed = Array.from({ length: 7 }, () => moomoo([1])); expect(fed[2]).toContain("a\nlittle healthier!"); expect(fed[4]).toContain("quite healthy!"); expect(fed[6]).toContain("totally healthy!"); expect(fed[0]).not.toContain("healthier"); // wMooMooBerries is saved with the other script bytes. expect(game.save().memory).toMatchObject({ wMooMooBerries: 7 }); expect(game.state.bag.items).toEqual([]); expect(game.state.events.has(EVENT_HEALED_MOOMOO)).toBe(true); expect(moomoo()).toContain("MILTANK: Mooo!"); }); describe("Mania's Shuckie (GiveShuckle, ReturnShuckie)", () => { it("lends Shuckie with its own OT, ID, nickname, Berry and gift caught data", () => { const { game } = world(); game.state.party = [mon(game, 155, 20)]; const shown = mania(game); expect(shown).toContain("after my #MON".replace("#", "POKé")); expect(shown).toContain("received a\nPOKéMON."); const shuckie = game.state.party[1]; expect(shuckie).toMatchObject({ species: SHUCKLE, level: 15, item: BERRY, otId: MANIA_OT_ID, ot: "MANIA", nickname: "SHUCKIE", caughtData: LANDMARK_GIFT, }); expect(game.state.pokedex.caught.has(SHUCKLE)).toBe(true); expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(true); expect(game.state.flags.has(ENGINE_GOT_SHUCKIE_TODAY)).toBe(true); // The same day he only asks the player to look after it. expect(mania(game)).toContain("Take good care of"); expect(game.state.party).toHaveLength(2); }); it("keeps Shuckie when the party is full, or when the player says no", () => { const { game } = world(); game.state.party = Array.from({ length: 6 }, () => mon(game, 155, 20)); expect(mania(game)).toContain("Your POKéMON party\nis full."); expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(false); game.state.party = [mon(game, 155, 20)]; expect(mania(game, [0])).toContain("What'll\nI do if he comes"); expect(game.state.party).toHaveLength(1); }); /** Shuckie lent yesterday; today Mania asks for it back. */ const lent = () => { const { game, clock } = world(); game.state.party = [mon(game, 155, 20)]; mania(game); clock.now = new Date("2026-09-23T12:00:00"); return game; }; it("takes Shuckie back the next day", () => { const game = lent(); const shown = mania(game, [1, 1]); expect(shown).toContain("may I have\nit back?"); expect(shown).toContain("Thank you!"); expect(game.state.party.map((m) => m.species)).toEqual([155]); expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true); expect(mania(game)).toContain("piness is being"); }); it("lets a happy Shuckie (150 or more) stay, and refuses another Pokémon", () => { const wrong = lent(); expect(mania(wrong, [1, 0])).toContain("you don't\nhave my POKéMON"); expect(wrong.state.party).toHaveLength(2); // Declining his question, or backing out of the party menu, is "robbed". expect(mania(wrong, [0])).toContain("same as being\nrobbed."); expect(mania(wrong, [1, -1])).toContain("same as being\nrobbed."); const happy = lent(); happy.state.party[1].happiness = 150; expect(mania(happy, [1, 1])).toContain("come to like you."); expect(happy.state.party).toHaveLength(2); expect(happy.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true); }); it("keeps Shuckie when every other Pokémon has fainted, with the wrong speech", () => { // CheckCurPartyMonFainted checks the party apart from Shuckie; // Mania then answers SHUCKIE_FAINTED with his happiness speech // (docs/bugs_and_glitches.md: the "last Pokémon" text is unused). const game = lent(); game.state.party[0].hp = 0; const shown = mania(game, [1, 1]); expect(shown).toContain("piness is being"); expect(shown).not.toContain("If I take my"); expect(game.state.party).toHaveLength(2); expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(false); }); }); describe("the Poké Seer (PokeSeer)", () => { /** The Seer stands at (2, 3); choose party slot `slot`. */ const seer = (game: GameWorld, slot: number) => talk(game, SEERS_HOUSE, 2, 4, "up", [slot]); it("reads where, when and at what level the player met a Pokémon", () => { const { game } = world(); game.state.party = [ mon(game, 155, 40, { nickname: "EMBER", caughtData: caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN), }), ]; const shown = seer(game, 0); expect(shown).toContain("I see all."); expect(shown).toMatch(/I see you met\nEMBER here:\nNEW BARK/); expect(shown).toContain("The time was\nDay!"); expect(shown).toContain("Its level was 10!"); // Level 40 now, 30 levels on: SeerAdviceTexts' 59 row. expect(shown).toContain("EMBER has\ngrown. It's gained\nmuch strength."); }); it("compares only the high byte of the OT ID, so some trades read as met", () => { const { game } = world(); const caught = caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN); game.state.party = [ mon(game, 155, 12, { otId: game.playerId ^ 0x0100, ot: "RED", caughtData: caught }), mon(game, 155, 12, { otId: game.playerId ^ 0x0001, ot: "RED", caughtData: caught }), ]; expect(seer(game, 0)).toContain("came from RED\nin a trade?"); expect(seer(game, 1)).toContain("I see you met"); }); it("cannot read gifts or blank data, reads only levels for events, and refuses Eggs", () => { const { game } = world(); game.state.party = [ mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_GIFT }), mon(game, 155, 20, { caughtData: 0 }), mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_EVENT }), mon(game, 175, 5, { egg: true }), ]; expect(seer(game, 0)).toContain("I can't\ntell a thing!"); expect(seer(game, 1)).toContain("I can't\ntell a thing!"); const event = seer(game, 2); expect(event).toContain("What!? Incredible!"); expect(event).not.toContain("The time was"); expect(seer(game, 3)).toContain("That's an EGG!"); expect(seer(game, -1)).toContain("you'd do nothing!"); }); it("keeps the last caught level when a Pokémon's is unknown", () => { // GetCaughtLevel prints ??? and leaves wSeerCaughtLevel as it was, // which SeerAdvice still subtracts from the level. The port keeps the // Seer's own last reading until the game is loaded again. const { game } = world(); game.state.party = [ mon(game, 155, 40, { caughtData: caughtData(DAY, 30, 0, LANDMARK_NEW_BARK_TOWN) }), mon(game, 155, 35, { nickname: "SMOKE", caughtData: LANDMARK_NEW_BARK_TOWN }), ]; seer(game, 0); game.interact(); // still facing the Seer const shown = settle(game, [1]).join(" "); expect(shown).toContain("Its level was ???!"); expect(shown).toContain("The time was\nUnknown!"); // 35 - 30 = 5: the first row (9), not 35 - 0. expect(shown).toContain("little more care."); // After a load the scratch byte starts at 0 again: 35 - 0 = 35. expect(seer(game, 1)).toContain("much strength."); }); }); describe("the Photo Studio (PhotoStudio)", () => { /** The photographer stands at (2, 3). */ const photo = (game: GameWorld, answers: number[]) => talk(game, PHOTO_STUDIO, 2, 4, "up", answers); it("finds no printer and takes no photo", () => { const { game } = world(); game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })]; const shown = photo(game, [1, 0]); expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?"); expect(shown).toContain("Hold\nstill for a bit."); // PrintPartymon's handshake reads $ff: PRINTER_ERROR_2. expect(shown).toContain( " Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\n\nPress B to Cancel", ); expect(shown).toContain("Oh, no picture?\nCome again, OK?"); expect(shown).not.toContain("Presto"); expect(photo(game, [1, 1])).toContain("An EGG? My talent\nis worth more…"); expect(photo(game, [1, -1])).toContain("Oh, no picture?"); }); }); });