import { 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 { ScriptRun, emptyScriptState, type ScriptState } from "../src/game/scripts/interpreter"; import { defaultTextContext } from "../src/game/scripts/text"; import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon"; import { tmhmItem, tmhmNumber } from "../src/game/rom/pokemon"; import type { GameWorld } from "../src/game/world/world"; import { battleFixtureWorld } from "./battle-fixture"; import { settle, walkTo } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; describe("askforphonenumber and addcellnum", () => { const host = { mapId: "1:1", lastTalked: 0, sceneOwner: () => undefined, timeMask: () => 2, clock: () => ({ weekday: 0, hours: 12, minutes: 0 }), clockDay: () => 0, text: () => defaultTextContext("KAWA"), }; /** Run `command contact`, then setevent 1, 2 or 3 for a result of 0, 1 * or 2; returns the event set and the phone list. */ const run = (command: number, phone: number[], answer?: number) => { const bytes = Uint8Array.from([ command, 16, 0x06, 0, 15, 0, // ifequal 0, $0f 0x06, 1, 19, 0, // ifequal 1, $13 0x06, 2, 23, 0, // ifequal 2, $17 0x91, 0x33, 1, 0, 0x91, 0x33, 2, 0, 0x91, 0x33, 3, 0, 0x91, ]); const state: ScriptState = { ...emptyScriptState(), phone: [...phone] }; const script = new ScriptRun(new RomReader(bytes), 0, state, host); let effect = script.resume(); if (effect?.kind === "yesno") effect = script.resume(answer); expect(effect).toBeUndefined(); return { result: [...script.state.events][0] - 1, phone: script.state.phone }; }; const others = [5, 6, 7, 11, 12, 13, 14, 15, 17]; it("registers a contact, keeping room for Mom and Elm until they are listed", () => { expect(run(0x97, [], 1)).toEqual({ result: 0, phone: [16] }); // Eight others leave no room: two slots wait for Mom (1) and Elm (4). expect(run(0x97, others.slice(0, 8), 1).result).toBe(1); // With Mom and Elm listed, the tenth slot is free. expect(run(0x97, [1, 4, ...others.slice(0, 7)], 1)).toMatchObject({ result: 0 }); // An already listed contact cannot be added again: also "full". expect(run(0x97, [16], 1).result).toBe(1); expect(run(0x97, [], 0)).toEqual({ result: 2, phone: [] }); }); it("addcellnum answers TRUE when the contact could not be added", () => { expect(run(0x28, [])).toEqual({ result: 0, phone: [16] }); expect(run(0x28, [16])).toMatchObject({ result: 1 }); }); }); describe.skipIf(!existsSync(romPath))("Violet City scripts with the local ROM", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); const ROUTE_31 = "26:2", KYLES_HOUSE = "10:11", SPEAROW = 21, BELLSPROUT = 69, CYNDAQUIL = 155, FLOWER_MAIL = 0x9e; const mon = (world: GameWorld, species: number, level = 10): PartyMon => generatePartyMon(world.assets.pokemon, { species, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0, }); /** Advance to the next choice (or until the world is idle). */ const toChoice = (world: GameWorld) => { const pages: string[] = []; for (let tick = 0; tick < 3000; 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: ${JSON.stringify(world.dialogue)} ${world.diagnostic}`); }; const place = (world: GameWorld, map: string, x: number, y: number) => { world.restore({ ...world.save(), map, x, y }); settle(world); }; /** From where the player stands, talk to the object facing `direction`, * answering in order. */ const talk = (world: GameWorld, direction: "up" | "right", answers: number[]) => { world.move(direction); world.interact(); const pages = toChoice(world); for (const answer of answers) { world.choose(answer); pages.push(...toChoice(world)); } settle(world); expect(world.notice, world.diagnostic).toBe(""); return pages.join(" "); }; describe("Route 31's sleepy man and Kenya's MAIL", () => { const setup = (options: { message?: string; party?: (world: GameWorld) => PartyMon[] } = {}) => { const world = battleFixtureWorld(assets()); // Kenya's MAIL text is not one the mail entry could write, so it is // set after the save is restored. place(world, ROUTE_31, 17, 8); // Route31MailRecipientScript: faceplayer, opentext, checkevent // EVENT_GOT_TM50_NIGHTMARE, iftrue, checkevent EVENT_GOT_KENYA. const script = world.assets.map(ROUTE_31).objects[0].script, r = world.assets.reader; const gotKenya = r.word(script + 9); world.state.events.add(gotKenya); const spearow = mon(world, SPEAROW); spearow.item = FLOWER_MAIL; spearow.mail = { message: options.message ?? "DARK CAVE leads\nto another road", author: "RANDY", authorId: 1, species: SPEAROW, type: FLOWER_MAIL, }; world.state.party.splice(0, world.state.party.length, ...(options.party?.(world) ?? [mon(world, CYNDAQUIL), spearow])); if (!options.party) world.state.party[1] = spearow; return { world, spearow }; }; it("takes Kenya and gives TM50", () => { const { world } = setup(); const pages = talk(world, "up", [1]); expect(pages).toContain("NIGHTMARE"); expect(world.state.party.map((each) => each.species)).toEqual([CYNDAQUIL]); expect(world.state.bag.tmsHms[tmhmNumber(tmhmItem(50)) - 1]).toBe(1); }); it("keeps the Pokémon for the wrong MAIL, no MAIL, B or the last that can fight", () => { const wrong = setup({ message: "HELLO" }); expect(talk(wrong.world, "up", [1])).toContain("This MAIL isn't for me."); expect(wrong.world.state.party).toHaveLength(2); const none = setup(); talk(none.world, "up", [0]); expect(none.world.state.party).toHaveLength(2); expect(none.world.state.bag.tmsHms.every((count) => count === 0)).toBe(true); const refused = setup(); talk(refused.world, "up", [-1]); expect(refused.world.state.party).toHaveLength(2); const last = setup({ party: (world) => { const fainted = mon(world, CYNDAQUIL); fainted.hp = 0; return [fainted]; }, }); last.world.state.party.push(last.spearow); talk(last.world, "up", [1]); expect(last.world.state.party).toHaveLength(2); }); }); describe("Kyle's trade", () => { /** Walk up beside Kyle, who walks up and down, and talk to him. */ const kyle = (world: GameWorld, answers: number[]) => { const id = world.assets.map(KYLES_HOUSE).objects[1].id; for (let attempt = 0; attempt < 20; attempt++) { const object = world.objects().find((each) => each.id === id)!; try { walkTo(world, object.x - 1, object.y); } catch { for (let tick = 0; tick < 16; tick++) world.tick(); continue; } const now = world.objects().find((each) => each.id === id)!; if (now.x === world.x + 1 && now.y === world.y) return talk(world, "right", answers); } throw new Error("Kyle kept walking away."); }; it("turns down the wrong Pokémon and a refusal, then trades once", () => { const world = battleFixtureWorld(assets()); world.state.party.push(mon(world, BELLSPROUT, 7)); place(world, KYLES_HOUSE, 3, 6); const wrong = kyle(world, [1, 0]); expect(wrong).toContain("BELLSPROUT"); expect(world.state.tradeFlags).toBe(0); kyle(world, [0]); expect(world.state.tradeFlags).toBe(0); kyle(world, [1, 1]); expect(world.state.tradeFlags).toBe(1 << 1); expect(world.state.party[1]).toMatchObject({ species: 95, level: 7, nickname: "ROCKY", ot: "KYLE" }); // Afterwards Kyle only remembers the trade. const save = JSON.stringify(world.save().party); kyle(world, []); expect(JSON.stringify(world.save().party)).toBe(save); }); }); it("leaves an Egg unhealed at the Pokémon Center", () => { const world = battleFixtureWorld(assets()); const egg = { ...mon(world, 175, 5), egg: true as const, nickname: "EGG", hp: 0 }; world.state.party.push(egg); world.state.party[0].hp = 1; // Violet's nurse stands behind the counter at 3,1. place(world, "10:10", 3, 3); talk(world, "up", [1]); expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]); expect(world.state.party[1]).toMatchObject({ egg: true, hp: 0 }); }); it("teaches a TM from the pack, refusing incompatible Pokémon and known moves", () => { const world = battleFixtureWorld(assets()); const data = world.assets.pokemon, move = data.tmhmMove(31), CATERPIE = 10; world.state.bag.tmsHms[30] = 2; // TM31 const lead = world.state.party[0]; expect(data.canLearnTMHM(lead.species, move)).toBe(true); expect(data.canLearnTMHM(CATERPIE, move)).toBe(false); world.state.party.push(mon(world, CATERPIE)); const choose = (label: string) => { toChoice(world); const choice = world.dialogue!.choice!; if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`); const index = choice.items.indexOf(label); expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0); world.choose(index); }; /** PACK, TM31, USE, YES, then the party slot; back to the pack. */ const teach = (slot: number) => { const pages = toChoice(world); const choice = world.dialogue!.choice!; if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`); world.choosePackEntry(3, 0); choose("USE"); pages.push(...toChoice(world)); world.choose(1); pages.push(...toChoice(world)); const party = world.dialogue!.choice!; expect(party.kind === "menu" && party.details).toEqual(["ABLE", "NOT ABLE"]); world.choose(slot); pages.push(...toChoice(world)); return pages.join(" "); }; const happiness = lead.happiness; expect(world.openStartMenu()).toBe(true); choose("PACK"); expect(teach(1)).toContain("is not compatible with CATERPIE"); expect(teach(0)).toContain("learned MUD-SLAP!"); expect(teach(0)).toContain("knows MUD-SLAP."); for (let i = 0; i < 10 && world.busy; i++) { if (world.dialogue?.choice) world.advanceDialogue(); toChoice(world); } settle(world); expect(world.state.party[0].moves).toContain(move); expect(world.state.party[0].happiness).toBeGreaterThan(happiness); expect(world.state.bag.tmsHms[30]).toBe(1); }); });