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, maxPP } from "../src/game/party/pokemon"; 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 NEW_BARK_TOWN = "24:4", PLAYERS_HOUSE = "24:6", CYNDAQUIL = 155, QUILAVA = 156, PIDGEY = 16, PIKACHU = 25, RAICHU = 26; const BICYCLE = 7, ANTIDOTE = 9, FULL_RESTORE = 0x0e, POTION = 0x12, REPEL = 0x14, THUNDERSTONE = 0x17, HP_UP = 0x1a, RARE_CANDY = 0x20, REVIVE = 0x27, PP_UP = 0x3e, ETHER = 0x3f, ELIXER = 0x41, EVERSTONE = 0x70, ENERGYPOWDER = 0x79, LEFTOVERS = 0x92, FLOWER_MAIL = 0x9e, BERRY = 0xad; const SOFTBOILED = 0x87; const CUT = 0x0f, GUST = 0x10, SAND_ATTACK = 0x1c, TACKLE = 0x21, LEER = 0x2b, SMOKESCREEN = 0x6c; const POISONED = 0x08; describe.skipIf(!existsSync(romPath))("the start menu", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** Standing in New Bark Town (outdoors) with two Pokémon and a pack. */ function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) { const world = new GameWorld( assets(), () => new Date("2026-09-25T12:00:00"), () => 0x42, ); 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 }, { item: ANTIDOTE, quantity: 1 }, ]; world.state.bag.keyItems = [BICYCLE]; world.state.scenes[NEW_BARK_TOWN] = 1; world.restore({ ...world.save(), map, x, y, direction: "down", objects: [], }); return world; } /** Acknowledge pages 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 (page && 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."); } function menuItems(world: GameWorld) { const choice = world.dialogue?.choice; return choice?.kind === "menu" ? choice.items : []; } /** Choose the menu item matching `label` (the whole label, or a regex). */ function pick(world: GameWorld, label: string | RegExp) { const items = menuItems(world); const index = items.findIndex((item) => typeof label === "string" ? item === label : label.test(item), ); expect(index, `${label} in ${items.join(", ")}`).toBeGreaterThanOrEqual(0); world.choose(index); return until(world); } /** Choose the pack entry for `item` in its pocket. */ function pickItem(world: GameWorld, item: number) { const choice = world.dialogue?.choice; if (choice?.kind !== "pack") throw new Error(`Expected the pack, got ${choice?.kind}`); for (const [pocket, { entries }] of choice.pockets.entries()) { const index = entries.findIndex((entry) => entry.item === item); if (index >= 0) { world.choosePackEntry(pocket, index); return until(world); } } throw new Error(`Item ${item} is not in the pack.`); } function open(world: GameWorld) { expect(world.openStartMenu()).toBe(true); return until(world); } /** B until every menu has closed. */ 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.diagnostic).toBe(""); } it("lists the original items for the player's progress", () => { const world = fixture(); open(world); expect(menuItems(world)).toEqual([ "POKéMON", "PACK", "CHRIS", "SAVE", "OPTION", "EXIT", ]); const choice = world.dialogue?.choice; expect(choice?.kind === "menu" && choice.details?.[1]).toBe("Contains items"); pick(world, "EXIT"); expect(world.busy).toBe(false); world.state.flags.add(ENGINE_FLAGS.pokedex); world.state.flags.add(ENGINE_FLAGS.pokegear); open(world); expect(menuItems(world)).toEqual([ "POKéDEX", "POKéMON", "PACK", "POKéGEAR", "CHRIS", "SAVE", "OPTION", "EXIT", ]); // The cursor stays on the last choice. pick(world, "POKéGEAR"); expect(world.notice).toBe("POKéGEAR is not ported yet."); close(world); open(world); const reopened = world.dialogue?.choice; expect(reopened?.kind === "menu" && reopened.items[reopened.selected]).toBe( "POKéGEAR", ); close(world); }); it("opens only while the player stands still", () => { const world = fixture(); world.tick("left"); expect(world.activity).toBe("walking"); expect(world.openStartMenu()).toBe(false); }); it("shows the Pokémon submenu with field moves first", () => { const world = fixture(); world.state.party[1].moves = [TACKLE, CUT, 0, 0]; open(world); expect(pick(world, "POKéMON")).toEqual(["Choose a POKéMON."]); expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]); pick(world, "CYNDAQUIL"); expect(menuItems(world)).toEqual(["STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]); pick(world, "CANCEL"); pick(world, "PIDGEY"); expect(menuItems(world)).toEqual(["CUT", "STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]); // CutFunction's CheckBadge, then the party menu again. expect(pick(world, "CUT")).toEqual([ "Sorry! A new BADGE is required.", "Choose a POKéMON.", ]); // STATS shows the stats screen, and B returns to the party. pick(world, "PIDGEY"); world.choose(menuItems(world).indexOf("STATS")); expect(world.statsScreen?.species).toBe(PIDGEY); world.advanceDialogue(); expect(until(world)).toEqual(["Choose a POKéMON."]); expect(world.statsScreen).toBeUndefined(); close(world); }); it("shares HP with Softboiled, which needs more than a fifth of it", () => { const world = fixture(); const data = world.assets.pokemon; const chansey = generatePartyMon(data, { species: 113, level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }); chansey.moves = [SOFTBOILED, 0, 0, 0]; world.state.party.push(chansey); world.state.party[0].hp = 3; const fifth = Math.floor(chansey.stats[0] / 5); open(world); pick(world, "POKéMON"); pick(world, "CHANSEY"); expect(pick(world, "SOFTBOILED")).toEqual(["Use on which POKéMON?"]); // Not on itself, and not on a Pokémon at full HP. expect(pick(world, "CHANSEY")).toEqual([ "That can't be used on this POKéMON.", "Use on which POKéMON?", ]); expect(pick(world, "PIDGEY")[0]).toBe("That can't be used on this POKéMON."); expect(pick(world, "CYNDAQUIL")).toEqual([ `CYNDAQUIL recovered ${Math.min(fifth, world.liveState.party[0].stats[0] - 3)}HP!`, "Choose a POKéMON.", ]); // The cursor returns to the user. const party = world.dialogue?.choice; expect(party?.kind === "menu" && party.selected).toBe(2); close(world); expect(world.state.party[2].hp).toBe(chansey.stats[0] - fifth); expect(world.state.party[0].hp).toBe( Math.min(world.state.party[0].stats[0], 3 + fifth), ); world.state.party[2].hp = fifth; open(world); pick(world, "POKéMON"); pick(world, "CHANSEY"); expect(pick(world, "SOFTBOILED")).toEqual([ "Not enough HP!", "Choose a POKéMON.", ]); close(world); }); it("switches two Pokémon, and B cancels the move", () => { const world = fixture(); open(world); pick(world, "POKéMON"); pick(world, "CYNDAQUIL"); expect(pick(world, "SWITCH")).toEqual(["Move to where?"]); expect(menuItems(world)).toEqual(["▷CYNDAQUIL", "PIDGEY"]); world.advanceDialogue(); until(world); expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]); pick(world, "CYNDAQUIL"); pick(world, "SWITCH"); pick(world, "PIDGEY"); expect(menuItems(world)).toEqual(["PIDGEY", "CYNDAQUIL"]); // Nothing commits until the menu closes. expect(world.save().party?.[0].species).toBe(CYNDAQUIL); close(world); expect(world.state.party.map((mon) => mon.species)).toEqual([PIDGEY, CYNDAQUIL]); // wPartyMenuCursor stays on the row chosen last, in the next menu too. open(world); pick(world, "POKéMON"); const party = world.dialogue?.choice; expect(party?.kind === "menu" && party.selected).toBe(1); close(world); }); it("reorders moves with their PP", () => { const world = fixture(); const mon = world.state.party[0]; expect(mon.moves).toEqual([TACKLE, LEER, SMOKESCREEN, 0]); mon.pp[0] = 30; open(world); pick(world, "POKéMON"); pick(world, "CYNDAQUIL"); expect(pick(world, "MOVE")).toEqual(["CYNDAQUIL :L8"]); expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]); expect(pick(world, "TACKLE")).toEqual(["Where?"]); // B while choosing where cancels only the move being moved. world.advanceDialogue(); until(world); pick(world, "TACKLE"); pick(world, "SMOKESCREEN"); expect(menuItems(world)).toEqual(["SMOKESCREEN", "LEER", "TACKLE"]); close(world); expect(world.state.party[0].moves).toEqual([SMOKESCREEN, LEER, TACKLE, 0]); expect(world.state.party[0].pp).toEqual([20, 30, 30, 0]); }); it("gives, swaps and takes held items", () => { const world = fixture(); world.state.bag.items.push({ item: LEFTOVERS, quantity: 1 }); open(world); pick(world, "POKéMON"); pick(world, "CYNDAQUIL"); pick(world, "ITEM"); expect(menuItems(world)).toEqual(["GIVE", "TAKE"]); expect(pick(world, "TAKE")).toEqual([ "CYNDAQUIL isn't holding anything.", "Choose a POKéMON.", ]); pick(world, "CYNDAQUIL"); pick(world, "ITEM"); pick(world, "GIVE"); // Key items cannot be held; the pack stays open. expect(pickItem(world, BICYCLE)).toEqual(["This item can't be held."]); expect(pickItem(world, POTION)).toEqual([ "Made CYNDAQUIL hold POTION.", "Choose a POKéMON.", ]); pick(world, "CYNDAQUIL"); pick(world, "ITEM"); pick(world, "GIVE"); expect(pickItem(world, LEFTOVERS)).toEqual([ "CYNDAQUIL is already holding", "POTION. Switch items?", ]); world.choose(1); expect(until(world)).toEqual([ "Took CYNDAQUIL's POTION and", "made it hold LEFTOVERS.", "Choose a POKéMON.", ]); close(world); expect(world.state.party[0].item).toBe(LEFTOVERS); expect(world.state.bag.items).toEqual([ { item: POTION, quantity: 5 }, { item: ANTIDOTE, quantity: 1 }, ]); open(world); pick(world, "POKéMON"); pick(world, "CYNDAQUIL"); pick(world, "ITEM"); expect(pick(world, "TAKE")[0]).toBe("Took LEFTOVERS from CYNDAQUIL."); close(world); expect(world.state.party[0].item).toBe(0); expect(world.state.bag.items.at(-1)).toEqual({ item: LEFTOVERS, quantity: 1 }); }); it("keeps a held item when the pack is full", () => { const world = fixture(); world.state.party[0].item = LEFTOVERS; world.state.bag.items = Array.from({ length: 20 }, (_, i) => ({ item: [POTION, ANTIDOTE, BERRY][i % 3], quantity: 99, })); open(world); pick(world, "POKéMON"); pick(world, "CYNDAQUIL"); pick(world, "ITEM"); expect(pick(world, "TAKE")[0]).toBe("Item storage space full."); close(world); expect(world.state.party[0].item).toBe(LEFTOVERS); }); it("refuses to compose MAIL without changing anything", () => { const world = fixture(); world.state.bag.items.push({ item: FLOWER_MAIL, quantity: 1 }); open(world); pick(world, "PACK"); pickItem(world, FLOWER_MAIL); expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]); expect(pick(world, "GIVE")).toEqual(["To which POKéMON?"]); pick(world, "CYNDAQUIL"); expect(world.notice).toBe("Writing MAIL is not ported yet."); close(world); expect(world.state.party[0].item).toBe(0); expect(world.state.bag.items.at(-1)).toEqual({ item: FLOWER_MAIL, quantity: 1 }); }); it("uses healing items from the pack on the party", () => { const world = fixture(); const [cyndaquil, pidgey] = world.state.party; cyndaquil.hp = 5; pidgey.status = POISONED; world.state.bag.items.push( { item: REVIVE, quantity: 1 }, { item: FULL_RESTORE, quantity: 1 }, ); open(world); pick(world, "PACK"); // The pack opens on the Items pocket, with the ROM's pocket names. const pack = world.dialogue?.choice; expect( pack?.kind === "pack" && [pack.pocket, pack.pockets.map((pocket) => pocket.name)], ).toEqual([0, ["ITEM POCKET", "BALL POCKET", "KEY POCKET", "TM POCKET"]]); pickItem(world, POTION); expect(menuItems(world)).toEqual(["USE", "GIVE", "TOSS", "QUIT"]); expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]); // Full HP: no effect, the Potion stays and the pack is shown again. expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]); expect(world.dialogue?.choice?.kind).toBe("pack"); pickItem(world, POTION); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL recovered 20HP!"]); pickItem(world, ANTIDOTE); pick(world, "USE"); expect(pick(world, "PIDGEY")).toEqual(["PIDGEY's cured of poison."]); // A Revive on a Pokémon that has not fainted has no effect. pickItem(world, REVIVE); pick(world, "USE"); expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]); close(world); const [healed, cured] = world.state.party; expect(healed.hp).toBe(25); expect(cured.status).toBe(0); expect(world.state.bag.items).toEqual([ { item: POTION, quantity: 4 }, { item: REVIVE, quantity: 1 }, { item: FULL_RESTORE, quantity: 1 }, ]); world.state.party[1].hp = 0; world.state.party[0].hp = 1; world.state.party[0].status = POISONED; open(world); pick(world, "PACK"); pickItem(world, REVIVE); pick(world, "USE"); expect(pick(world, "PIDGEY")).toEqual(["PIDGEY is revitalized."]); pickItem(world, FULL_RESTORE); pick(world, "USE"); const max = world.state.party[0].stats[0]; expect(pick(world, "CYNDAQUIL")).toEqual([`CYNDAQUIL recovered ${max - 1}HP!`]); close(world); expect(world.state.party[1].hp).toBe(world.state.party[1].stats[0] >> 1); expect(world.state.party[0].hp).toBe(max); expect(world.state.party[0].status).toBe(0); expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 4 }]); }); it("makes bitter herbs cost happiness", () => { const world = fixture(); world.state.party[0].hp = 1; world.state.bag.items.push({ item: ENERGYPOWDER, quantity: 1 }); const happiness = world.state.party[0].happiness; open(world); pick(world, "PACK"); pickItem(world, ENERGYPOWDER); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual([ "CYNDAQUIL recovered 24HP!", "It looks bitter…", ]); close(world); expect(world.state.party[0].happiness).toBe(happiness - 5); }); it("restores and raises PP", () => { const world = fixture(); world.state.party[0].pp = [10, 30, 3, 0]; world.state.bag.items.push( { item: ETHER, quantity: 1 }, { item: ELIXER, quantity: 1 }, { item: PP_UP, quantity: 2 }, ); open(world); pick(world, "PACK"); pickItem(world, ETHER); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["Restore the PP of which move?"]); expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]); // B returns to the party menu. world.advanceDialogue(); expect(until(world)).toEqual(["Use on which POKéMON?"]); pick(world, "CYNDAQUIL"); // Full PP: no effect. expect(pick(world, "LEER")).toEqual(["It won't have any effect."]); pickItem(world, ETHER); pick(world, "USE"); pick(world, "CYNDAQUIL"); expect(pick(world, "TACKLE")).toEqual(["PP was restored."]); pickItem(world, ELIXER); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["PP was restored."]); pickItem(world, PP_UP); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["Raise the PP of which move?"]); expect(pick(world, "TACKLE")).toEqual(["TACKLE's PP increased."]); close(world); // Tackle: 10, +10 (Ether), +10 (Elixer), then a PP Up adds a fifth of // the base PP (7 of 35) to both the current PP and the maximum. const pp = world.state.party[0].pp; expect(pp).toEqual([0x40 | 37, 30, 13, 0]); expect(maxPP(world.assets.pokemon, TACKLE, 1) & 63).toBe(42); const items = world.state.bag.items; expect(items.find((slot) => slot.item === PP_UP)?.quantity).toBe(1); expect(items.some((slot) => slot.item === ETHER || slot.item === ELIXER)).toBe(false); }); it("refuses a PP Up on a move that has had three", () => { const world = fixture(); world.state.party[0].pp[1] = 0xc0 | 30; world.state.bag.items.push({ item: PP_UP, quantity: 1 }); open(world); pick(world, "PACK"); pickItem(world, PP_UP); pick(world, "USE"); pick(world, "CYNDAQUIL"); // PPIsMaxedOutText, then the move list again. expect(pick(world, "LEER")).toEqual([ "LEER's PP is maxed out.", "Raise the PP of which move?", ]); close(world); expect(world.state.party[0].pp[1]).toBe(0xc0 | 30); expect(world.state.bag.items.at(-1)).toEqual({ item: PP_UP, quantity: 1 }); }); it("tosses items after confirming the quantity", () => { const world = fixture(); open(world); pick(world, "PACK"); pickItem(world, POTION); pick(world, "TOSS"); const choice = world.dialogue?.choice; expect(choice?.kind === "martquantity" && [choice.prompt, choice.max]).toEqual([ "Throw away how\nmany?", 5, ]); world.choose(3); expect(until(world)).toEqual(["Throw away 3 POTION(S)?"]); world.choose(0); until(world); pickItem(world, POTION); pick(world, "TOSS"); world.choose(3); until(world); world.choose(1); expect(until(world)).toEqual(["Threw away POTION(S)."]); close(world); expect(world.state.bag.items[0]).toEqual({ item: POTION, quantity: 2 }); }); it("gets on the Bicycle from the Key pocket, or gives Oak's warning indoors", () => { const world = fixture(); open(world); pick(world, "PACK"); pickItem(world, BICYCLE); expect(menuItems(world)).toEqual(["USE", "SEL", "QUIT"]); world.choose(0); expect(settle(world).join(" ")).toContain("got on the\nBICYCLE."); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.playerState).toBe(1); const indoors = fixture(PLAYERS_HOUSE, 7, 4); open(indoors); pick(indoors, "PACK"); pickItem(indoors, BICYCLE); expect(pick(indoors, "USE")).toEqual([ "OAK: CHRIS! This isn't the time to use that!", ]); close(indoors); expect(indoors.state.playerState).toBe(0); }); it("registers the Bicycle with SEL, and Select rides it without a message", () => { const world = fixture(); // Nothing registered yet. expect(world.useSelect()).toBe(true); expect(until(world)).toEqual([ "An item in your PACK may be", "registered for use on SELECT Button.", ]); close(world); open(world); pick(world, "PACK"); pickItem(world, BICYCLE); expect(pick(world, "SEL")).toEqual(["Registered the BICYCLE."]); close(world); expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE }); // The registration is saved. world.restore(world.save()); expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE }); expect(world.useSelect()).toBe(true); expect(settle(world)).toEqual([]); expect(world.diagnostic).toBe(""); expect(world.state.playerState).toBe(1); expect(world.useSelect()).toBe(true); expect(settle(world)).toEqual([]); expect(world.state.playerState).toBe(0); const indoors = fixture(PLAYERS_HOUSE, 7, 4); indoors.state.registeredItem = { which: 0x81, item: BICYCLE }; indoors.useSelect(); expect(until(indoors)).toEqual(["OAK: CHRIS! This isn't the time to use that!"]); close(indoors); expect(indoors.state.playerState).toBe(0); }); it("forgets a registered item that left its slot", () => { const world = fixture(); world.state.registeredItem = { which: 0x02, item: POTION }; world.useSelect(); expect(until(world)[0]).toBe("An item in your PACK may be"); close(world); expect(world.state.registeredItem).toEqual({ which: 0, item: 0 }); }); it("offers no USE without a field menu, and a notice for unported effects", () => { const world = fixture(); world.state.bag.items.push( { item: BERRY, quantity: 1 }, { item: REPEL, quantity: 1 }, ); world.state.bag.balls = [{ item: 5, quantity: 3 }]; open(world); pick(world, "PACK"); // Poké Balls have no field menu: GIVE and TOSS only. pickItem(world, 5); expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]); pick(world, "QUIT"); pickItem(world, REPEL); pick(world, "USE"); expect(world.notice).toBe("Using REPEL is not ported yet."); close(world); expect(world.state.bag.items.find((slot) => slot.item === REPEL)).toBeTruthy(); }); it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => { const world = fixture(); const pidgey = world.state.party[1]; pidgey.moves = [TACKLE, CUT, LEER, GUST]; pidgey.pp = [35, 30, 30, 35]; pidgey.hp = 0; world.state.bag.items.push({ item: RARE_CANDY, quantity: 2 }); const data = world.assets.pokemon; open(world); pick(world, "PACK"); pickItem(world, RARE_CANDY); pick(world, "USE"); const pages = pick(world, "PIDGEY"); expect(pages.slice(0, 2)).toEqual([ "PIDGEY grew to level 5!", pages[1], ]); expect(pages[1]).toMatch(/^ATTACK \d+ DEFENSE \d+ SPCL\.ATK \d+ SPCL\.DEF \d+ SPEED \d+$/); expect(pages.slice(2)).toEqual([ "PIDGEY is trying to learn SAND-ATTACK.", "But PIDGEY can't learn more than four moves.", "Delete an older move to make room for SAND-ATTACK?", ]); world.choose(1); expect(until(world)).toEqual(["Which move should be forgotten?"]); expect(menuItems(world)).toEqual(["TACKLE", "CUT", "LEER", "GUST"]); expect(pick(world, "CUT")).toEqual([ "HM moves can't be forgotten now.", "Which move should be forgotten?", ]); expect(pick(world, "LEER")).toEqual([ "1, 2 and… Poof!", "PIDGEY forgot LEER.", "And…", "PIDGEY learned SAND-ATTACK!", ]); close(world); const mon = world.state.party[1]; expect(mon.level).toBe(5); expect(mon.exp).toBe(data.expAtLevel(data.baseData(PIDGEY).growthRate, 5)); expect(mon.moves).toEqual([TACKLE, CUT, SAND_ATTACK, GUST]); expect(mon.pp[2]).toBe(data.move(SAND_ATTACK).pp); // The Rare Candy adds the maximum HP's rise even to a fainted Pokémon. expect(mon.hp).toBe(mon.stats[0] - pidgey.stats[0]); expect(mon.hp).toBeGreaterThan(0); expect(world.state.bag.items.at(-1)).toEqual({ item: RARE_CANDY, quantity: 1 }); }); it("can decline to learn the move", () => { const world = fixture(); world.state.party[1].moves = [TACKLE, CUT, LEER, GUST]; world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 }); open(world); pick(world, "PACK"); pickItem(world, RARE_CANDY); pick(world, "USE"); pick(world, "PIDGEY"); world.choose(0); expect(until(world)).toEqual(["Stop learning SAND-ATTACK?"]); world.choose(1); expect(until(world)).toEqual(["PIDGEY did not learn SAND-ATTACK."]); close(world); expect(world.state.party[1].moves).toEqual([TACKLE, CUT, LEER, GUST]); expect(world.state.party[1].level).toBe(5); }); it("evolves after a Rare Candy, and B can stop it", () => { for (const stop of [false, true]) { const world = fixture(); const data = world.assets.pokemon; world.state.party[0] = generatePartyMon(data, { species: CYNDAQUIL, level: 13, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }); world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 }); open(world); pick(world, "PACK"); pickItem(world, RARE_CANDY); pick(world, "USE"); world.choose(0); // Acknowledge pages until the evolution animation starts. const pages: string[] = []; for (let i = 0; i < 200 && !world.evolution; i++) { const dialogue = world.dialogue; if (dialogue) { pages.push(dialogue.pages[dialogue.index].replace(/\n/g, " ")); if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } else world.tick(); } expect(pages[0]).toBe("CYNDAQUIL grew to level 14!"); expect(pages.at(-1)).toBe("What? CYNDAQUIL is evolving!"); expect(world.picture).toBe(CYNDAQUIL); // B does nothing before the flashes begin. world.advanceDialogue(); expect(world.evolution).toBeDefined(); for (let tick = 0; tick < 40; tick++) world.tick(); if (stop) { world.advanceDialogue(); expect(until(world)).toEqual(["Huh? CYNDAQUIL stopped evolving!"]); } else { expect(until(world)).toEqual([ "Congratulations! Your CYNDAQUIL", "evolved into QUILAVA!", ]); } close(world); const mon = world.state.party[0]; expect(mon.level).toBe(14); expect([mon.species, mon.nickname]).toEqual( stop ? [CYNDAQUIL, "CYNDAQUIL"] : [QUILAVA, "QUILAVA"], ); expect(mon.stats).toEqual( calcStats(data, mon.species, mon.dvs, 14, mon.statExp), ); expect(world.state.pokedex.caught.has(QUILAVA)).toBe(!stop); expect(world.state.bag.items.some((slot) => slot.item === RARE_CANDY)).toBe(false); } }); it("evolves with a stone, which B cannot stop, unless held back by an Everstone", () => { const world = fixture(); const data = world.assets.pokemon; world.state.party[1] = generatePartyMon(data, { species: PIKACHU, level: 10, item: EVERSTONE, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }); world.state.party[1].nickname = "SPARKY"; world.state.bag.items.push({ item: THUNDERSTONE, quantity: 2 }); open(world); pick(world, "PACK"); pickItem(world, THUNDERSTONE); expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]); const party = world.dialogue?.choice; expect(party?.kind === "menu" && party.details).toEqual(["NOT ABLE", "ABLE"]); expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]); pickItem(world, THUNDERSTONE); pick(world, "USE"); expect(pick(world, "SPARKY")).toEqual(["It won't have any effect."]); close(world); world.state.party[1].item = 0; open(world); pick(world, "PACK"); pickItem(world, THUNDERSTONE); pick(world, "USE"); world.choose(1); for (let i = 0; i < 20 && !world.evolution; i++) { if (world.dialogue?.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } for (let tick = 0; tick < 60; tick++) world.tick(); world.advanceDialogue(); expect(until(world)).toEqual([ "Congratulations! Your SPARKY", "evolved into RAICHU!", ]); close(world); const mon = world.state.party[1]; // A nickname stays. expect([mon.species, mon.nickname]).toEqual([RAICHU, "SPARKY"]); expect(world.state.bag.items.at(-1)).toEqual({ item: THUNDERSTONE, quantity: 1 }); }); it("raises stat experience with a vitamin, up to its limit", () => { const world = fixture(); const data = world.assets.pokemon; const mon = world.state.party[0]; const hp = mon.hp, happiness = mon.happiness; world.state.bag.items.push({ item: HP_UP, quantity: 2 }); open(world); pick(world, "PACK"); pickItem(world, HP_UP); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL's HEALTH rose."]); close(world); const raised = world.state.party[0]; expect(raised.statExp[0]).toBe(2560); expect(raised.stats).toEqual(calcStats(data, CYNDAQUIL, raised.dvs, 8, raised.statExp)); expect(raised.hp).toBe(hp); expect(raised.happiness).toBeGreaterThan(happiness); raised.statExp[0] = 100 << 8; open(world); pick(world, "PACK"); pickItem(world, HP_UP); pick(world, "USE"); expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]); close(world); expect(world.state.party[0].statExp[0]).toBe(100 << 8); expect(world.state.bag.items.at(-1)).toEqual({ item: HP_UP, quantity: 1 }); }); it("saves the state from before the menu while it is open", () => { const world = fixture(); world.state.party[0].hp = 5; open(world); pick(world, "PACK"); pickItem(world, POTION); pick(world, "USE"); pick(world, "CYNDAQUIL"); const saved = world.save(); expect(saved.party?.[0].hp).toBe(5); expect(saved.bag?.items[0].quantity).toBe(5); const reloaded = fixture(); reloaded.restore(saved); expect(reloaded.busy).toBe(false); expect(reloaded.state.party[0].hp).toBe(5); close(world); expect(world.state.party[0].hp).toBe(25); }); });