import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { C } from "../src/game/battle/constants"; import { OW } from "../src/game/world/constants"; import { trainerFixtureWorld } from "./trainer-fixture"; import { migrateSave, type GameWorld } from "../src/game/world/world"; import { DecorationData } from "../src/game/world/decorations"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const fixture = (tail?: number[], lose = false, winText = false) => trainerFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), tail, lose, winText); function finish(world: GameWorld) { const messages: string[] = []; for (let tick = 0; tick < 10000; tick++) { const view = world.battle; if (view) { if (view.message) messages.push(view.message.text); if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 }); else if (view.prompt?.kind === "yesno") world.battleChoose(1); else if (view.prompt?.kind === "forget") world.battleChoose(3); else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact(); else world.tick(); } else if (world.dialogue) world.advanceDialogue(); else if (world.busy) world.tick(); else return messages.join("\n"); } throw new Error(`Trainer battle did not settle: ${world.diagnostic}`); } describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", () => { it("commits Joey's ROM reward, party experience, sighting and postbattle event together", () => { const world = fixture(); const before = world.save(); world.interact(); expect(world.battle).toBeDefined(); expect(world.save()).toEqual(before); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("JOEY"); expect(world.state.money).toBe(before.money + 64); expect(world.state.party[0].exp).toBeGreaterThan(before.party[0].exp); expect(world.state.pokedex.seen.has(19)).toBe(true); expect(world.state.events.has(256)).toBe(true); const saved = world.save(); world.restore(saved); expect(world.save()).toEqual(saved); }); it("reads scripted win text from the script bank's ROM pointer", () => { const world = fixture(undefined, false, true); world.interact(); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("Ack! I lost again!"); expect(messages.indexOf("Ack! I lost again!")).toBeLessThan(messages.lastIndexOf("64")); }); it("caps trainer rewards at the money limit", () => { const world = fixture(); world.state.money = C.MAX_MONEY - 1; world.interact(); finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.money).toBe(C.MAX_MONEY); }); it("sends some of the reward to Mom", () => { const world = fixture(); world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY); world.state.momsMoney = 100; const before = world.save(); world.interact(); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(messages).toContain("Sent some to MOM!"); expect(world.state.money).toBe(before.money + 48); expect(world.state.momsMoney).toBe(116); expect(world.state.momItemTriggerBalance).toBe(OW.MOM_MONEY); }); describe("Mom's purchases (MomTriesToBuySomething)", () => { const SUPER_POTION = 0x11, MOM_ITEMS_1 = [ [SUPER_POTION, 600], [0x09, 90], // ANTIDOTE [0x05, 180], // POKE_BALL [0x13, 450], // ESCAPE_ROPE [0x04, 500], // GREAT_BALL ]; /** Beat Joey while Mom saves a quarter (¥16) of his ¥64; returns the * pages of the call that follows, if any. */ function win(world: GameWorld, momsMoney: number) { world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY); world.state.momsMoney = momsMoney - 16; world.interact(); const pages: string[] = []; // The queued call starts at the next PlayerEvents. let idle = 0; for (let tick = 0; tick < 10000; tick++) { const view = world.battle; if (view) { 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; } const dialogue = world.dialogue; if (dialogue) { const page = dialogue.pages[dialogue.index].replace(/\n/g, " "); if (pages.at(-1) !== page) pages.push(page); if (dialogue.holdTicks !== undefined) world.tick(); else world.advanceDialogue(); } else if (world.busy || idle++ < 2) world.tick(); else { expect(world.notice, world.diagnostic).toBe(""); return pages; } } throw new Error(`Trainer battle did not settle: ${world.diagnostic}`); } it("buys MomItems_2's next item once her savings reach its trigger", () => { const world = fixture(); const pcItems = world.state.pcItems.map((slot) => ({ ...slot })); const pages = win(world, 900); expect(pages).toEqual([ "Hi, CHRIS! How are you?", "I found a useful item shopping, so", "I bought it with your money. Sorry!", "It's in your PC. You'll like it!", "Click!", "……", ]); expect(world.state.momsMoney).toBe(300); expect(world.state.momItem).toBe(1); expect(world.state.pcItems).toEqual([...pcItems, { item: SUPER_POTION, quantity: 1 }]); // MomItems_2's next trigger is ¥4000. expect(win(world, 3999)).toEqual([]); expect(world.state.momItem).toBe(1); const saved = world.save(); expect([saved.momItem, saved.momItemTriggerBalance]).toEqual([1, 4600]); }); it("buys a random MomItems_1 item when her savings land on a ¥2300 step", () => { const world = fixture(); world.state.momItem = 1; const pages = win(world, 2300); expect(pages[1]).toBe("I found a useful item shopping, so"); const bought = world.state.pcItems.at(-1)!; const entry = MOM_ITEMS_1.find(([item]) => item === bought.item); expect(entry).toBeDefined(); expect(world.state.momsMoney).toBe(2300 - entry![1]); expect(world.state.momItem).toBe(1); expect(world.state.momItemTriggerBalance).toBe(4600); }); it("raises the ¥2300 step past her savings without buying anything", () => { const world = fixture(); world.state.momItem = 1; expect(win(world, 2400)).toEqual([]); expect(world.state.momsMoney).toBe(2400); expect(world.state.momItemTriggerBalance).toBe(4600); }); it("sends dolls to the player's room", () => { const world = fixture(); world.state.momItem = 3; // ¥10000: the Charmander doll for ¥1800. const pages = win(world, 10000); expect(pages.slice(0, 4)).toEqual([ "Hi, CHRIS! How are you?", "While shopping today, I saw this adorable doll, so", "I bought it with your money. Sorry!", "It's in your room. You'll love it!", ]); expect(world.state.momsMoney).toBe(8200); expect(world.state.momItem).toBe(4); const doll = new DecorationData(world.assets.reader, world.assets.pokemon); const charmander = Array.from({ length: 52 }, (_, i) => i + 1).find( (id) => doll.name(id) === "CHARMANDER DOLL", )!; expect(world.state.events.has(doll.attribute(charmander).event)).toBe(true); }); it("buys nothing while the PC is full", () => { const world = fixture(); world.state.pcItems = Array.from({ length: 50 }, (_, i) => ({ item: 0x20 + i, quantity: 1 })); expect(win(world, 900)).toEqual([]); expect(world.state.momsMoney).toBe(900); expect(world.state.momItem).toBe(0); }); it("migrates saves made before her purchases", () => { const world = fixture(); const { momItem: _, momItemTriggerBalance: __, ...old } = world.save(); const migrated = migrateSave(old, () => 0)!; expect([migrated.momItem, migrated.momItemTriggerBalance]).toEqual([0, OW.MOM_MONEY]); world.restore(migrated); expect(() => world.restore({ ...migrated, momItem: 11 })).toThrow(); }); }); it("rolls back reward, experience and sightings if the following script is unsupported", () => { const world = fixture([0xff]); const before = world.save(); world.interact(); finish(world); expect(world.notice).toContain("not been ported"); expect(world.save()).toEqual(before); }); it("restores a midbattle save without partially paying the reward", () => { const world = fixture(); const before = world.save(); world.interact(); for (let tick = 0; tick < 1000 && !world.battle?.prompt; tick++) { if (world.battle?.message?.prompt) world.interact(); else world.tick(); } expect(world.save()).toEqual(before); world.restore(before); expect(world.battle).toBeUndefined(); expect(world.save()).toEqual(before); }); it("CANLOSE scripts receive LOSE in wScriptVar without forcing a whiteout", () => { // ifequal LOSE, .lost; invalid; .lost: setevent 256; end const world = fixture([0x91], true); const clerk = world.assets.map("26:4").objects[0]; const start = clerk.script + 12; // after winlosstext/loadtrainer/loadvar/startbattle const target = ((start + 5) % 0x4000) + 0x4000; world.assets.reader.bytes.set([0x06, 1, target & 255, target >> 8, 0xff, 0x33, 0, 1, 0x91], start); const before = world.save(); world.interact(); const messages = finish(world); expect(world.notice, world.diagnostic).toBe(""); expect(world.state.events.has(256)).toBe(true); expect(world.state.money).toBe(before.money); expect(world.save().map).toBe(before.map); expect(messages).not.toMatch(/whited\s+out/i); }); });