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 { calcStats, generatePartyMon, validPartyMon, } from "../src/game/party/pokemon"; import { MAX_PC_ITEMS, emptyStorage, receivePCItem, toBoxMon, validStorage, } from "../src/game/party/storage"; import { removeFromSlot } from "../src/game/party/bag"; import { C } from "../src/game/battle/constants"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const POKEMON_CENTER = "26:5", CYNDAQUIL = 155, PIDGEY = 16, POTION = 18, MYSTERY_EGG = 69; describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** In front of Cherrygrove's PC (COLL_PC at 9,1). */ function fixture() { let seed = 5; const world = new GameWorld( assets(), () => new Date("2026-09-25T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 255; }, ); const data = world.assets.pokemon; const mon = (species: number, level: number) => generatePartyMon(data, { species, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }); world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)]; world.state.bag.items = [{ item: POTION, quantity: 5 }]; world.restore({ ...world.save(), map: POKEMON_CENTER, x: 9, y: 2, direction: "up", objects: [], }); return world; } /** Acknowledge pages and timed messages up to the next choice; returns * the pages seen. */ function until(world: GameWorld) { const pages: string[] = []; for (let tick = 0; tick < 500; tick++) { const dialogue = world.dialogue; if (!dialogue) { if (!world.busy) return pages; world.tick(); continue; } const page = dialogue.pages[dialogue.index].replace(/\n/g, " "); if (pages.at(-1) !== page) pages.push(page); const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice) return pages; if (last && dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } throw new Error("No choice appeared."); } /** Choose the menu item matching `label` (the whole label, or a regex). */ function pick(world: GameWorld, label: string | RegExp) { const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error(`Expected a menu, got ${choice?.kind}`); const index = choice.items.findIndex((item) => typeof label === "string" ? item === label : label.test(item), ); expect( index, `${label} in ${choice.items.join(", ")}`, ).toBeGreaterThanOrEqual(0); world.choose(index); return until(world); } function menuItems(world: GameWorld) { const choice = world.dialogue?.choice; return choice?.kind === "menu" ? choice.items : []; } function open(world: GameWorld) { world.interact(); expect(world.notice, world.diagnostic).toBe(""); return until(world); } /** Leave every menu with B until the script ends. */ function close(world: GameWorld) { for (let i = 0; i < 20 && world.busy; i++) { if (world.dialogue?.choice) world.advanceDialogue(); until(world); } settle(world); expect(world.notice, world.diagnostic).toBe(""); } it("lists Bill's PC and the player's PC, and Oak's PC after the Pokédex", () => { const world = fixture(); const pages = open(world); expect(pages).toEqual([ `${world.name} turned on the PC.`, "Access whose PC?", ]); expect(menuItems(world)).toEqual([ "BILL's PC", `${world.name}'s PC`, "TURN OFF", ]); expect(pick(world, "TURN OFF")).toContain("… Link closed…"); settle(world); world.state.flags.add(ENGINE_FLAGS.pokedex); open(world); expect(menuItems(world)).toEqual([ "BILL's PC", `${world.name}'s PC`, "PROF.OAK's PC", "TURN OFF", ]); }); it("refuses to start without a Pokémon", () => { const world = fixture(); world.state.party = []; world.interact(); expect(settle(world).join(" ").replace(/\n/g, " ")).toContain( "Bzzzzt! You must have a POKéMON to use this!", ); expect(world.notice, world.diagnostic).toBe(""); }); it("deposits, keeps the last Pokémon and withdraws with recalculated stats", () => { const world = fixture(); const data = world.assets.pokemon; const pidgey = world.state.party[1]; pidgey.hp = 1; pidgey.status = 0x08; pidgey.pp = [0, 0, 0, 0]; pidgey.statExp = [0xffff, 0xffff, 0xffff, 0xffff, 0xffff]; open(world); expect(pick(world, "BILL's PC")).toEqual([ "BILL's PC accessed.", "POKéMON Storage System opened.", "What?", ]); expect(menuItems(world)).toEqual([ "WITHDRAW POKéMON", "DEPOSIT POKéMON", "CHANGE BOX", "MOVE POKéMON W/O MAIL", "SEE YA!", ]); pick(world, /^DEPOSIT/); expect(world.dialogue?.pages[0]).toBe("PARTY POKéMON\nChoose a POKéMON."); expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY", "CANCEL"]); const choice = world.dialogue?.choice; expect(choice?.kind === "menu" && choice.details).toEqual([ ":L8", ":L4", "", ]); pick(world, "PIDGEY"); expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]); expect(pick(world, "DEPOSIT")).toContain("Stored PIDGEY!"); // A mid-PC save holds the committed game. expect(world.save().party).toHaveLength(2); expect(world.liveState.party).toHaveLength(1); const [stored] = world.liveState.storage.boxes[0]; expect(stored).not.toHaveProperty("hp"); // RestorePPOfDepositedPokemon restores PP; status and HP are dropped. expect(stored.pp).toEqual( stored.moves.map((move) => (move ? data.move(move).pp : 0)), ); // BillsPC_CheckMail_PreventBlackout keeps the last Pokémon. expect(menuItems(world)).toEqual(["CYNDAQUIL", "CANCEL"]); pick(world, "CYNDAQUIL"); expect(pick(world, "DEPOSIT")).toContain("It's your last POKéMON!"); expect(pick(world, "CANCEL")).toContain("What?"); pick(world, /^WITHDRAW/); expect(world.dialogue?.pages[0]).toBe("BOX1\nChoose a POKéMON."); pick(world, "PIDGEY"); expect(menuItems(world)).toEqual([ "WITHDRAW", "STATS", "RELEASE", "CANCEL", ]); expect(pick(world, "WITHDRAW")).toContain("Got PIDGEY!"); pick(world, "CANCEL"); close(world); const back = world.state.party[1]; expect(world.state.storage.boxes[0]).toEqual([]); // SendGetMonIntoFromBox: CalcMonStats with stat experience, full HP. expect(back.status).toBe(0); expect(back.hp).toBe(back.stats[0]); expect(back.stats).toEqual( calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp), ); expect(back.stats[0]).toBeGreaterThan(pidgey.stats[0]); expect(validPartyMon(data, back)).toBe(true); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("moves Pokémon between the party and boxes without mail", () => { const world = fixture(); const data = world.assets.pokemon; const [cyndaquil, pidgey] = world.state.party; pidgey.hp = 1; pidgey.pp = [1, 0, 0, 0]; const boxed = toBoxMon( generatePartyMon(data, { species: 19, // RATTATA level: 6, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x1234, caughtData: 0, }), ); world.state.storage.boxes[0] = [boxed]; open(world); pick(world, "BILL's PC"); expect(pick(world, "MOVE POKéMON W/O MAIL")).toEqual([ "Each time you move a POKéMON, data will be saved. OK?", ]); world.choose(1); until(world); // The party, then the fourteen boxes, with the current box highlighted. const boxes = world.dialogue?.choice; expect(boxes?.kind === "menu" && [boxes.items.length, boxes.selected]).toEqual([15, 1]); expect(menuItems(world)[0]).toBe("PARTY POKéMON"); // PIDGEY from the party to the end of BOX1: its PP are restored. pick(world, "PARTY POKéMON"); pick(world, /^PIDGEY/); expect(menuItems(world)).toEqual(["MOVE", "STATS", "CANCEL"]); pick(world, "MOVE"); pick(world, "BOX1"); expect(menuItems(world)).toEqual(["RATTATA", "▶"]); pick(world, "▶"); // RATTATA from BOX1 to the front of the party, with full HP. pick(world, "BOX1"); pick(world, /^RATTATA/); pick(world, "MOVE"); pick(world, "PARTY POKéMON"); pick(world, /^CYNDAQUIL/); expect(world.liveState.party.map((mon) => mon.species)).toEqual([19, 155]); // Within the party: RATTATA to the end. pick(world, "PARTY POKéMON"); pick(world, /^RATTATA/); pick(world, "MOVE"); pick(world, "PARTY POKéMON"); pick(world, "▶"); close(world); expect(world.state.party.map((mon) => mon.species)).toEqual([155, 19]); const rattata = world.state.party[1]; expect(rattata.hp).toBe(rattata.stats[0]); expect(rattata.stats).toEqual(calcStats(data, 19, 0x1234, 6, rattata.statExp)); expect(world.state.storage.boxes[0].map((mon) => mon.species)).toEqual([16]); expect(world.state.storage.boxes[0][0].pp[0]).toBe(data.move(pidgey.moves[0]).pp); expect(cyndaquil.species).toBe(155); }); it("keeps the last Pokémon and refuses a full destination when moving", () => { const world = fixture(); const data = world.assets.pokemon; world.state.party = [world.state.party[0]]; const full = Array.from({ length: 20 }, () => toBoxMon( generatePartyMon(data, { species: 16, level: 3, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0, caughtData: 0, }), ), ); world.state.storage.boxes[1] = full; world.state.storage.boxes[2] = [structuredClone(full[0])]; world.state.storage.boxes[2][0].nickname = "SPARE"; open(world); pick(world, "BILL's PC"); pick(world, "MOVE POKéMON W/O MAIL"); world.choose(1); until(world); pick(world, "PARTY POKéMON"); expect(pick(world, /^CYNDAQUIL/)).toEqual(["What's up?"]); expect(pick(world, "MOVE")).toContain("It's your last POKéMON!"); // BOX3 into the full BOX2: no room, and the position is chosen again. pick(world, "BOX3"); pick(world, /^SPARE/); pick(world, "MOVE"); pick(world, "BOX2"); expect(pick(world, "▶")).toContain("There's no room!"); // B leaves the destination list for the list of boxes to move from. world.advanceDialogue(); until(world); // Within the full BOX2 a Pokémon can still move. pick(world, "BOX2"); pick(world, /^PIDGEY/); pick(world, "MOVE"); pick(world, "BOX2"); expect(pick(world, "▶")).toContain("Saving… Leave ON!"); close(world); expect(world.state.storage.boxes[1]).toHaveLength(20); expect(world.state.storage.boxes[2].map((mon) => mon.nickname)).toEqual(["SPARE"]); expect(world.state.party).toHaveLength(1); }); it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => { const world = fixture(); const data = world.assets.pokemon; const pidgey = world.state.party[1]; pidgey.hp = 1; pidgey.status = 0x08; open(world); pick(world, "BILL's PC"); pick(world, /^DEPOSIT/); pick(world, "PIDGEY"); const stats = () => { const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error("Expected a menu"); world.choose(choice.items.indexOf("STATS")); for (let tick = 0; tick < 10 && !world.statsScreen; tick++) world.tick(); const shown = world.statsScreen; world.advanceDialogue(); until(world); return shown; }; // A party Pokémon shows its own HP and status. expect(stats()).toMatchObject({ species: PIDGEY, hp: 1, status: 0x08 }); expect(world.statsScreen).toBeUndefined(); expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]); pick(world, "DEPOSIT"); pick(world, "CANCEL"); pick(world, /^WITHDRAW/); pick(world, "PIDGEY"); // CalcTempmonStats: a boxed Pokémon shows computed stats, full HP and // no status. const boxed = stats()!; expect(boxed.status).toBe(0); expect(boxed.hp).toBe(boxed.stats[0]); expect(boxed.stats).toEqual( calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp), ); expect(menuItems(world)).toEqual(["WITHDRAW", "STATS", "RELEASE", "CANCEL"]); expect(world.notice, world.diagnostic).toBe(""); }); it("refuses to withdraw into a full party or deposit into a full box", () => { const world = fixture(); const party = world.state.party; world.state.party = Array.from({ length: 6 }, (_, i) => structuredClone(party[i % 2]), ); world.state.storage.boxes[0] = [toBoxMon(party[1])]; open(world); pick(world, "BILL's PC"); pick(world, /^WITHDRAW/); pick(world, "PIDGEY"); const full = pick(world, "WITHDRAW"); expect(full).toContain("The party's full!"); expect(full.at(-1)).toBe("What's up?"); pick(world, "CANCEL"); pick(world, "CANCEL"); world.liveState.storage.boxes[0] = Array.from({ length: 20 }, () => toBoxMon(party[1]), ); pick(world, /^DEPOSIT/); pick(world, "PIDGEY"); expect(pick(world, "DEPOSIT")).toContain("The BOX is full."); close(world); expect(world.state.party).toHaveLength(6); }); it("releases after confirmation and says goodbye by species", () => { const world = fixture(); const nicknamed = toBoxMon(world.state.party[1]); nicknamed.nickname = "BIRDY"; world.state.storage.boxes[0] = [nicknamed]; open(world); pick(world, "BILL's PC"); pick(world, /^WITHDRAW/); pick(world, "BIRDY"); expect(pick(world, "RELEASE")).toEqual(["Release POKéMON?"]); world.choose(0); expect(until(world)).toContain("What's up?"); pick(world, "RELEASE"); world.choose(1); expect(until(world)).toEqual([ "Released POKéMON.", "Bye, PIDGEY!", "BOX1 Choose a POKéMON.", ]); close(world); expect(world.state.storage.boxes[0]).toEqual([]); }); it("switches and names boxes", () => { const world = fixture(); world.state.storage.boxes[1] = [toBoxMon(world.state.party[1])]; open(world); pick(world, "BILL's PC"); pick(world, "CHANGE BOX"); expect(world.dialogue?.pages[0]).toBe("CURRENT\nBOX1\nChoose a BOX."); expect(menuItems(world)).toHaveLength(14); const choice = world.dialogue?.choice; expect(choice?.kind === "menu" && choice.details?.slice(0, 2)).toEqual([ "0/20", "1/20", ]); pick(world, "BOX2"); expect(menuItems(world)).toEqual(["SWITCH", "NAME", "PRINT", "QUIT"]); expect(pick(world, "SWITCH")).toEqual([ "When you change a POKéMON BOX, data will be saved. OK?", ]); world.choose(1); until(world); pick(world, "BOX3"); const naming = pick(world, "NAME"); expect(naming).toEqual(["BOX NAME?"]); world.choose("FAVS"); until(world); pick(world, "FAVS"); pick(world, "NAME"); // InitString: a blank name keeps the box's name. world.choose(" "); until(world); pick(world, "FAVS"); expect(pick(world, "PRINT")).toContain("There's no POKéMON."); pick(world, "BOX2"); pick(world, "PRINT"); const printer = world.dialogue?.choice; expect(printer?.kind === "screen" && printer.screen.label).toContain("Printer Error 2"); world.choose("a"); expect(world.dialogue?.choice).toEqual(printer); world.choose("b"); until(world); close(world); expect(world.state.storage.current).toBe(1); expect(world.state.storage.names[2]).toBe("FAVS"); open(world); pick(world, "BILL's PC"); pick(world, /^WITHDRAW/); expect(world.dialogue?.pages[0]).toBe("BOX2\nChoose a POKéMON."); expect(menuItems(world)).toEqual(["PIDGEY", "CANCEL"]); close(world); }); it("stores, withdraws and tosses items in the player's PC", () => { const world = fixture(); world.state.bag.keyItems = [MYSTERY_EGG]; open(world); expect(pick(world, `${world.name}'s PC`)).toEqual([ "Accessed own PC.", "Item Storage System opened.", "What do you want to do?", ]); expect(menuItems(world)).toEqual([ "WITHDRAW ITEM", "DEPOSIT ITEM", "TOSS ITEM", "MAIL BOX", "LOG OFF", ]); world.choose(1); let list = world.dialogue?.choice; expect( list?.kind === "martlist" && list.entries.map((e) => e.name), ).toEqual(["POTION", "MYSTERY EGG"]); world.choose(0); const quantity = world.dialogue?.choice; expect(quantity?.kind === "martquantity" && quantity.prompt).toBe( "How many do you\nwant to deposit?", ); world.choose(3); expect(until(world)).toContain("Deposited 3 POTION(S)."); // Key items cannot be tossed, so they move one at a time. world.choose(1); expect(until(world)).toContain("Deposited 1 MYSTERY EGG(S)."); world.choose(-1); until(world); expect(world.liveState.pcItems).toEqual([ { item: POTION, quantity: 3 }, { item: MYSTERY_EGG, quantity: 1 }, ]); expect(world.liveState.bag).toMatchObject({ items: [{ item: POTION, quantity: 2 }], keyItems: [], }); pick(world, "TOSS ITEM"); world.choose(1); expect(until(world)).toContain("That's too impor- tant to toss out!"); world.choose(0); world.choose(2); expect(until(world)).toEqual(["Throw away 2 POTION(S)?"]); world.choose(1); expect(until(world)).toContain("Discarded POTION(S)."); world.choose(-1); until(world); pick(world, "WITHDRAW ITEM"); world.choose(1); expect(until(world)).toContain("Withdrew 1 MYSTERY EGG(S)."); world.choose(0); world.choose(1); expect(until(world)).toContain("Withdrew 1 POTION(S)."); world.choose(-1); until(world); expect(pick(world, "MAIL BOX")).toContain("There's no MAIL here."); close(world); expect(world.state.pcItems).toEqual([]); expect(world.state.bag).toMatchObject({ items: [{ item: POTION, quantity: 3 }], keyItems: [MYSTERY_EGG], }); }); it("rates the Pokédex on Oak's PC", () => { const world = fixture(); world.state.flags.add(ENGINE_FLAGS.pokedex); for (const species of [1, 2, 3, 4, 5]) world.state.pokedex.seen.add(species); world.state.pokedex.caught.add(1); open(world); expect(pick(world, "PROF.OAK's PC")).toEqual([ "PROF.OAK's PC accessed.", "POKéDEX Rating System opened.", "Want to get your POKéDEX rated?", ]); world.choose(1); const pages = until(world).join(" "); expect(pages).toContain("Current POKéDEX completion level:"); expect(pages).toContain("5 POKéMON seen 1 POKéMON owned"); expect(pages).toContain("Look for POKéMON in grassy areas!"); expect(pages).toContain("The link to PROF. OAK's PC closed."); close(world); }); it("validates and migrates stored Pokémon and items", () => { const world = fixture(); const before = world.save(); const box = toBoxMon(world.state.party[1]); for (const [storage, pcItems] of [ [{ ...before.storage, current: 14 }, before.pcItems], [ { ...before.storage, boxes: before.storage.boxes.map((_, i) => i ? [] : Array.from({ length: 21 }, () => box), ), }, before.pcItems, ], [ { ...before.storage, boxes: before.storage.boxes.map((_, i) => i ? [] : [{ ...box, hp: 10 }], ), }, before.pcItems, ], [ { ...before.storage, names: before.storage.names.map(() => " ") }, before.pcItems, ], [before.storage, [{ item: C.POKE_BALL, quantity: 100 }]], [ before.storage, Array.from({ length: 51 }, () => ({ item: POTION, quantity: 1 })), ], ] as const) { expect(() => world.restore({ ...before, storage, pcItems })).toThrow( /Invalid native save/, ); expect(world.save()).toEqual(before); } const old = world.save() as Partial>; delete old.storage; delete old.pcItems; world.restore(old); expect(world.state.storage.boxes.every((box) => !box.length)).toBe(true); expect(world.state.storage.names.every((name) => name === null)).toBe(true); expect(world.state.pcItems).toEqual([]); }); }); describe("PC storage rules", () => { it("removes from the selected stack, or else the first match", () => { const list = [ { item: 1, quantity: 99 }, { item: 2, quantity: 5 }, { item: 1, quantity: 3 }, ]; expect(removeFromSlot(list, 2, 1, 3)).toBe(true); expect(list).toEqual([ { item: 1, quantity: 99 }, { item: 2, quantity: 5 }, ]); expect(removeFromSlot(list, 1, 1, 9)).toBe(true); expect(list[0]).toEqual({ item: 1, quantity: 90 }); expect(removeFromSlot(list, 1, 2, 6)).toBe(false); expect(removeFromSlot(list, 0, 3, 1)).toBe(false); }); it("holds fifty stacks, filling existing stacks first", () => { const items = Array.from({ length: MAX_PC_ITEMS }, (_, i) => ({ item: i + 1, quantity: 98, })); expect(receivePCItem(items, 7, 1)).toBe(true); expect(items[6].quantity).toBe(99); expect(receivePCItem(items, 7, 1)).toBe(false); expect(receivePCItem(items, 99, 1)).toBe(false); }); it("accepts only complete storage", () => { const storage = emptyStorage(); expect(validStorage(storage)).toBe(true); expect(validStorage({ ...storage, boxes: storage.boxes.slice(1) })).toBe( false, ); expect(validStorage({ ...storage, names: [...storage.names, null] })).toBe( false, ); expect( validStorage({ ...storage, names: ["LONGNAME9", ...storage.names.slice(1)], }), ).toBe(false); expect( validStorage({ ...storage, names: ["GOOD ONE", ...storage.names.slice(1)], }), ).toBe(true); expect(validStorage({ ...storage, current: -1 })).toBe(false); }); });