import { beforeAll, 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 { generatePartyMon } from "../src/game/party/pokemon"; import { settle, walkTo } from "./world-driver"; import { questFixtureWorld, questLabFixtureWorld, questRivalFixtureWorld } from "./quest-fixture"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const HOUSE = "26:10", EGG = 0x45; describe.skipIf(!existsSync(romPath))("Mr. Pokémon's house with the local ROM", () => { function fixture(species = 155, refuseHealing = false) { const bytes = new Uint8Array(readFileSync(romPath)); // FadeInFromBlackSpecial's table entry: refuse the routine after rewards // and healing to check the complete interaction's rollback boundary. if (refuseHealing) bytes.set([0, 0, 0x40], 0xc0bf); const assets = new CrystalAssets(bytes); const world = new GameWorld(assets, () => new Date("2026-09-24T12:00:00")); const mon = generatePartyMon(assets.pokemon, { species, level: 5, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0, }); mon.hp = 1; mon.status = 8; mon.pp[0] = 0; world.state.party = [mon]; world.state.events.add(species === 155 ? 27 : species === 158 ? 28 : 29); world.state.events.add(1794); world.state.events.add(1939); world.restore({ ...world.save(), map: "26:1", x: 17, y: 6, direction: "up" }); expect(world.move("up")).toBe(true); expect(world.mapId).toBe(HOUSE); return world; } it.each([[155, 1601], [158, 1602], [152, 1600]])("gives the Egg and Pokédex, heals starter %i, and sets the return quest", (species, stolenBall) => { const world = fixture(species); const pages = settle(world).join("\n"); expect(world.notice, world.diagnostic).toBe(""); expect(pages).toContain("MYSTERY EGG"); expect(pages).toContain("DEX"); expect(world.state.bag.keyItems).toEqual([EGG]); expect(world.state.flags.has(11)).toBe(true); for (const event of [30, 1737, 1725, 1938, stolenBall]) expect(world.state.events.has(event), String(event)).toBe(true); for (const event of [1794, 1939]) expect(world.state.events.has(event)).toBe(false); expect(world.state.lastSpawnMap).toBe("26:3"); expect(world.state.scenes).toMatchObject({ [HOUSE]: 1, "26:3": 1, "24:5": 3 }); expect(world.state.specialPhoneCall).toBe(2); expect(world.objects().some((object) => object.id === 3)).toBe(false); const mon = world.state.party[0]; expect(mon.hp).toBe(mon.stats[0]); expect(mon.status).toBe(0); expect(mon.pp[0]).toBe(world.assets.pokemon.move(mon.moves[0]).pp); // The completed scene and Oak's removal survive reload without replay. world.restore(world.save()); expect(settle(world)).toEqual([]); expect(world.objects().some((object) => object.id === 3)).toBe(false); world.interact(); expect(settle(world).join("\n")).toContain("depending on"); expect(world.state.bag.keyItems).toEqual([EGG]); }); it("reloads during Oak's speech from committed state and replays rewards once", () => { const world = fixture(); for (let tick = 0; tick < 3000; tick++) { if (world.dialogue?.pages[world.dialogue.index].includes("OAK: Aha")) break; if (world.dialogue) world.advanceDialogue(); else world.tick(); } expect(world.dialogue?.pages[world.dialogue.index]).toContain("OAK: Aha"); const saved = world.save(); expect(saved.bag.keyItems).toEqual([]); expect(saved.events).not.toContain(30); world.restore(saved); expect(settle(world).join("\n")).toContain("MYSTERY EGG"); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.bag.keyItems).toEqual([EGG]); }); it("rolls back rewards, healing and Oak's departure when a late command is refused", () => { const world = fixture(155, true); const before = world.save(); for (let tick = 0; tick < 5000 && !world.notice; tick++) { if (world.dialogue) world.advanceDialogue(); else world.tick(); } expect(world.notice).toContain("not been ported"); expect(world.state.bag.keyItems).toEqual([]); expect(world.state.party[0]).toEqual(before.party[0]); expect(world.state.events.has(30)).toBe(false); expect(world.state.events.has(1737)).toBe(false); expect(world.state.lastSpawnMap).toBe(before.lastSpawnMap); expect(world.state.flags.has(11)).toBe(false); expect(world.objects().find((object) => object.id === 3)).toMatchObject({ x: 6, y: 5 }); expect(world.save()).toMatchObject({ x: before.x, y: before.y }); }); }); describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the local ROM", () => { const RIVAL = 3; // CHERRYGROVECITY_RIVAL, after the Guide Gent let assets: CrystalAssets; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); }); /** Play through scripts and battles, fighting with the first move. */ const drive = (world: GameWorld) => { const shown: string[] = []; for (let tick = 0; tick < 50_000; tick++) { const view = world.battle; if (view) { if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text); if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact(); else world.tick(); continue; } if (world.dialogue) { shown.push(world.dialogue.pages[world.dialogue.index]); if (world.dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); continue; } if (!world.busy) return shown.join("\n"); world.tick(); } throw new Error(`The world did not settle: ${world.diagnostic}`); }; // loadtrainer RIVAL1, RIVAL1_1_. it.each([[158, 152], [152, 155], [155, 158]])("with starter %i, battles the rival's %i and commits the exit", (species, rivalSpecies) => { const world = questRivalFixtureWorld(assets, species); const money = world.state.money; expect(world.move("right")).toBe(true); const shown = drive(world); expect(world.notice, world.diagnostic).toBe(""); // TrainerClassNames' "RIVAL" and Rival1Group's placeholder name "?". expect(shown).toContain("RIVAL ?\nwas defeated!"); expect(shown).toContain("My name's ???."); expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true); expect(world.state.money).toBe(money + 300); expect(world.state.scenes["26:3"]).toBe(0); // SCENE_CHERRYGROVECITY_NOOP expect(world.objects().some((object) => object.id === RIVAL)).toBe(false); const mon = world.state.party[0]; expect(mon.hp).toBe(mon.stats[0]); // The finished scene does not replay after reload. world.restore(world.save()); expect(drive(world)).toBe(""); expect(world.objects().some((object) => object.id === RIVAL)).toBe(false); }); it("runs CherrygroveRivalSceneNorth from the east as well", () => { const world = questRivalFixtureWorld(assets); world.restore({ ...world.save(), x: 34, y: 6, direction: "left" }); expect(world.move("left")).toBe(true); expect(drive(world)).toContain("RIVAL ?\nwas defeated!"); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.scenes["26:3"]).toBe(0); expect(world.objects().some((object) => object.id === RIVAL)).toBe(false); world.restore(world.save()); }); it("continues the scene after a loss without whiting out (BATTLETYPE_CANLOSE)", () => { const world = questRivalFixtureWorld(assets); const mon = world.state.party[0]; mon.hp = 1; mon.moves = [43, 0, 0, 0]; // Leer cannot win. mon.pp = [30, 0, 0, 0]; const money = world.state.money; expect(world.move("right")).toBe(true); const shown = drive(world); expect(world.notice, world.diagnostic).toBe(""); expect(shown).toContain("fainted!"); expect(shown).toContain("waste of time"); expect(world.mapId).toBe("26:3"); expect(world.state.money).toBe(money); expect(world.state.scenes["26:3"]).toBe(0); expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]); }); }); describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", () => { const ELM_CALLED = 67; // EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON let assets: CrystalAssets; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); }); const afterHouse = () => { const world = questFixtureWorld(assets); expect(world.move("up")).toBe(true); settle(world); expect(world.state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED return world; }; it("waits indoors, then rings on the next outdoor step and ends with the hang-up", () => { const world = afterHouse(); // SpecialCallOnlyWhenOutside: a step inside the house does not ring. expect(world.move("left") || world.move("right")).toBe(true); expect(settle(world)).toEqual([]); expect(world.state.specialPhoneCall).toBe(2); world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] }); expect(world.move("down")).toBe(true); const pages = settle(world); expect(world.notice, world.diagnostic).toBe(""); expect(pages[0]).toContain("It's a disaster!"); // HangUp: "Click!", then Phone_CallEnd's boops. expect(pages.slice(-2)).toEqual(["Click!", "……"]); expect(world.state.specialPhoneCall).toBe(0); expect(world.state.events.has(ELM_CALLED)).toBe(true); world.restore(world.save()); expect(world.move("down")).toBe(true); expect(settle(world)).toEqual([]); }); it("replays the whole call after a reload mid-call, from the committed step", () => { const world = afterHouse(); world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] }); expect(world.move("down")).toBe(true); for (let tick = 0; tick < 500 && !world.dialogue; tick++) world.tick(); expect(world.dialogue?.pages[0]).toContain("disaster"); const saved = world.save(); expect(saved.specialPhoneCall).toBe(2); expect(saved.events).not.toContain(ELM_CALLED); expect(saved).toMatchObject({ x: 17, y: 8 }); world.restore(saved); expect(world.move("down")).toBe(true); expect(settle(world).slice(-2)).toEqual(["Click!", "……"]); expect(world.state.specialPhoneCall).toBe(0); }); }); describe.skipIf(!existsSync(romPath))("the return to Elm's lab with the local ROM", () => { const LAB = "24:5", OFFICER = 7, ELM = 2; // ELMSLAB_OFFICER, ELMSLAB_ELM let assets: CrystalAssets; beforeAll(() => { assets = new CrystalAssets(new Uint8Array(readFileSync(romPath))); }); /** Enter the lab and step onto MeetCopScript's tile, answering the naming screen. */ const meetOfficer = (name: string) => { const world = questLabFixtureWorld(assets); expect(world.move("up")).toBe(true); expect(world.mapId).toBe(LAB); walkTo(world, 4, 6); expect(world.move("up")).toBe(true); return { world, pages: settle(world, [name]) }; }; it("names the rival on the naming screen and remembers it across reload", () => { const { world, pages } = meetOfficer("BLUE"); expect(world.notice, world.diagnostic).toBe(""); expect(pages).toContain("RIVAL'S NAME?"); expect(pages).toContain("OK! So BLUE\nwas his name."); expect(world.state.rivalName).toBe("BLUE"); expect(world.state.scenes[LAB]).toBe(2); // SCENE_ELMSLAB_NOOP expect(world.objects().some((object) => object.id === OFFICER)).toBe(false); world.restore(world.save()); expect(world.state.rivalName).toBe("BLUE"); expect(world.objects().some((object) => object.id === OFFICER)).toBe(false); }); it.each(["", " "])("InitName gives the blank name %j the ROM default", (name) => { const { world, pages } = meetOfficer(name); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.rivalName).toBe("SILVER"); expect(pages).toContain("OK! So SILVER\nwas his name."); }); it("replays the officer scene from the committed step after a reload at the naming screen", () => { const world = questLabFixtureWorld(assets); world.move("up"); walkTo(world, 4, 6); world.move("up"); expect(settle(world)).toContain("RIVAL'S NAME?"); // waits: no name queued const saved = world.save(); expect(saved.rivalName).toBe("???"); expect(saved).toMatchObject({ x: 4, y: 6 }); world.restore(saved); expect(world.move("up")).toBe(true); settle(world, ["GARY"]); expect(world.state.rivalName).toBe("GARY"); }); it("hands the Egg to Elm, then the aide gives five Poké Balls", () => { const { world } = meetOfficer("BLUE"); walkTo(world, 5, 3); world.move("up"); // Elm stands at 5,2: this only turns the player. world.interact(); const elm = settle(world).join("\n"); expect(world.notice, world.diagnostic).toBe(""); expect(elm).toContain("handed\nthe MYSTERY EGG"); expect(world.state.bag.keyItems).toEqual([]); expect(world.state.scenes[LAB]).toBe(6); // SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS expect(world.state.scenes["24:3"]).toBe(1); // SCENE_ROUTE29_CATCH_TUTORIAL const aide = walkTo(world, 4, 8).join("\n"); expect(world.notice, world.diagnostic).toBe(""); expect(aide).toContain("received\nPOKé BALL."); expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]); // POKE_BALL world.restore(world.save()); expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]); expect(world.objects().some((object) => object.id === ELM)).toBe(true); }); });