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 { generatePartyMon } from "../src/game/party/pokemon"; import { ENGINE_FLAGS } from "../src/game/rom/profile"; import { OW } from "../src/game/world/constants"; import { shiftClockHour, type MomBankButton, type MomBankView, } from "../src/game/scripts/mom"; import { settle } from "./world-driver"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; const PLAYERS_HOUSE_1F = "24:6", CYNDAQUIL = 155; const EVENT_GOT_A_POKEMON_FROM_ELM = 26, EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30, EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64, EVENT_FIRST_TIME_BANKING_WITH_MOM = 118; const GET = 0, SAVE = 1, CHANGE = 2; describe("DSTChecks' clock shift", () => { it("moves the clock an hour, carrying across the half-day offset limit", () => { const clock = { minuteOffset: 30, weekdayOffset: 2, dst: false }; shiftClockHour(clock, 1); expect(clock).toEqual({ minuteOffset: 90, weekdayOffset: 2, dst: false }); clock.minuteOffset = 700; shiftClockHour(clock, 1); // 760 minutes ahead is a day ahead less 680 minutes. expect(clock).toMatchObject({ minuteOffset: -680, weekdayOffset: 3 }); shiftClockHour(clock, -1); expect(clock).toMatchObject({ minuteOffset: -740 + 1440, weekdayOffset: 2, }); clock.minuteOffset = -700; clock.weekdayOffset = 0; shiftClockHour(clock, -1); expect(clock).toMatchObject({ minuteOffset: 680, weekdayOffset: 6 }); }); }); describe.skipIf(!existsSync(romPath))("BankOfMom", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); /** The player faces Mom in the living room on a Friday, after the * Mystery Egg was delivered. */ function fixture(time = "2026-09-25T12:00:00") { const world = new GameWorld( assets(), () => new Date(time), () => 0x42, ); world.state.party = [ generatePartyMon(world.assets.pokemon, { species: CYNDAQUIL, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }), ]; world.state.flags.add(ENGINE_FLAGS.pokegear); for (const event of [ EVENT_GOT_A_POKEMON_FROM_ELM, EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON, EVENT_GAVE_MYSTERY_EGG_TO_ELM, ]) world.state.events.add(event); world.state.scenes[PLAYERS_HOUSE_1F] = 1; world.state.money = 3000; world.restore({ ...world.save(), map: PLAYERS_HOUSE_1F, x: 7, y: 3, direction: "down", objects: [], }); return world; } /** A world whose bank is already open, as after the first visit. */ function banking(time?: string) { const world = fixture(time); world.state.flags.add(OW.ENGINE_MOM_ACTIVE); world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST); world.state.momsMoney = 2300; 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 < 2000; 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 talk(world: GameWorld) { world.interact(); return until(world); } function answer(world: GameWorld, value: number) { world.choose(value); return until(world); } function view(world: GameWorld): MomBankView { const choice = world.dialogue?.choice; if (choice?.kind !== "mombank") throw new Error(`Expected Mom's amount box, got ${choice?.kind}`); return choice.view; } function press(world: GameWorld, ...buttons: MomBankButton[]) { let pages: string[] = []; for (const button of buttons) { if (button === "a") world.interact(); else if (button === "b") world.advanceDialogue(); else world.moveCursor(button); pages = until(world); } return pages; } /** Open the menu and choose GET, SAVE or CHANGE. */ function menu(world: GameWorld, item: number) { expect(talk(world)).toEqual([ "Hi! Welcome home! You're trying very hard, I see.", "I've kept your room tidy.", "Or is this about your money?", ]); const pages = answer(world, 1); expect(pages).toEqual(["What do you want to do?"]); const choice = world.dialogue?.choice; expect(choice).toMatchObject({ kind: "menu", items: ["GET", "SAVE", "CHANGE", "CANCEL"], selected: 0, }); return answer(world, item); } /** Close the last text and check the interaction ended cleanly. */ function finish(world: GameWorld) { expect(until(world)).toEqual([]); settle(world); expect(world.busy).toBe(false); expect(world.diagnostic).toBe(""); } /** Key in an amount digit by digit, from the leftmost of six digits. */ function enter(world: GameWorld, amount: number) { const digits = String(amount).padStart(6, "0"); press(world, "left", "left", "left", "left", "left"); for (const digit of digits) { for (let i = 0; i < Number(digit); i++) press(world, "up"); press(world, "right"); } expect(view(world).amount).toBe(amount); } it("offers to save money the first time, then only cheers the player on", () => { const world = fixture(); const offer = talk(world); expect(offer[0]).toBe("Wow, that's a cute POKéMON."); expect(offer.at(-1)).toBe("Do you want me to save your money?"); expect(world.dialogue?.choice?.kind).toBe("yesno"); // Saves made during the visit keep the state from before it. expect(world.save().events).not.toContain( EVENT_FIRST_TIME_BANKING_WITH_MOM, ); world.choose(1); const pages = [...until(world)]; expect(pages.slice(0, 3)).toEqual([ "OK, I'll take care of your money.", "………………", "Be careful.", ]); expect(pages.at(-1)).toBe("Now, go on!"); finish(world); expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true); expect(world.state.events.has(EVENT_FIRST_TIME_BANKING_WITH_MOM)).toBe( true, ); expect( world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), ).toBe(true); // Until New Bark Town's callback clears it, Mom only cheers. const cheer = talk(world); expect(cheer.join(" ")).toMatch(/behind you/i); finish(world); }); it("does not start saving when the player declines", () => { const world = fixture(); talk(world); const pages = answer(world, 0); expect(pages[0]).toBe("Be careful."); finish(world); expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false); }); it("deposits money, refusing more than the player holds", () => { const world = banking(); expect(menu(world, SAVE)).toEqual(["How much do you want to save?"]); expect(view(world)).toEqual({ labels: ["SAVED", "HELD", "DEPOSIT"], saved: 2300, held: 3000, amount: 0, cursor: 5, }); enter(world, 5000); expect(press(world, "a")).toEqual([ "You don't have that much.", "How much do you want to save?", ]); // .StoreMoney starts again from zero on the last digit. expect(view(world)).toMatchObject({ amount: 0, cursor: 5 }); enter(world, 1000); expect(press(world, "a")).toEqual(["Your money's safe here! Get going!"]); finish(world); expect(world.state.money).toBe(2000); expect(world.state.momsMoney).toBe(3300); }); it("withdraws money, refusing more than Mom saved", () => { const world = banking(); expect(menu(world, GET)).toEqual(["How much do you want to take?"]); expect(view(world).labels[2]).toBe("WITHDRAW"); enter(world, 2301); expect(press(world, "a")).toEqual([ "You haven't saved that much.", "How much do you want to take?", ]); enter(world, 2300); expect(press(world, "a")).toEqual(["CHRIS, don't give up!"]); finish(world); expect(world.state.money).toBe(5300); expect(world.state.momsMoney).toBe(0); }); it("refuses amounts that would pass MAX_MONEY on either side", () => { const world = banking(); world.state.money = 999_000; menu(world, GET); enter(world, 1000); expect(press(world, "a")).toEqual([ "You can't take that much.", "How much do you want to take?", ]); enter(world, 999); press(world, "a"); finish(world); expect(world.state.money).toBe(999_999); expect(world.state.momsMoney).toBe(1301); world.state.momsMoney = 999_000; menu(world, SAVE); enter(world, 1000); expect(press(world, "a")).toEqual([ "You can't save that much.", "How much do you want to save?", ]); press(world, "b"); finish(world); expect(world.state.momsMoney).toBe(999_000); }); it("steps digits through GiveMoney and TakeMoney's limits", () => { const world = banking(); menu(world, SAVE); // The cursor stops at both ends. press(world, "right"); expect(view(world).cursor).toBe(5); press(world, "down"); expect(view(world).amount).toBe(0); press(world, "up", "up", "left", "up"); expect(view(world)).toMatchObject({ amount: 12, cursor: 4 }); for (let i = 0; i < 6; i++) press(world, "left"); expect(view(world).cursor).toBe(0); for (let i = 0; i < 10; i++) press(world, "up"); expect(view(world).amount).toBe(999_999); press(world, "down"); expect(view(world).amount).toBe(899_999); for (let i = 0; i < 9; i++) press(world, "down"); expect(view(world).amount).toBe(0); // Start and Select are not read. world.choose("start"); expect(view(world).amount).toBe(0); // A with nothing entered cancels. expect(press(world, "a")).toEqual(["Just do what you can."]); finish(world); expect(world.state.momsMoney).toBe(2300); }); it("cancels with B, from the box and from the menu", () => { const world = banking(); menu(world, SAVE); press(world, "up"); expect(press(world, "b")).toEqual(["Just do what you can."]); finish(world); expect(world.state.money).toBe(3000); menu(world, -1); finish(world); }); it("starts or stops her savings with CHANGE", () => { const world = banking(); expect(menu(world, CHANGE)).toEqual(["Do you want to save some money?"]); expect(answer(world, 1)).toEqual([ "OK, I'll save your money. Trust me!", "CHRIS, stick with it!", ]); finish(world); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true); menu(world, CHANGE); expect(answer(world, 0)).toEqual(["Just do what you can."]); finish(world); expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false); expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true); }); it("asks about Daylight Saving Time when it is not about money", () => { const world = banking(); talk(world); expect(answer(world, 0)).toEqual([ "Do you want to switch to Daylight Saving Time?", ]); expect(answer(world, 1)).toEqual([ "I set the clock forward by one hour.", "Just do what you can.", ]); finish(world); expect(world.state.clock.dst).toBe(true); expect(world.clock().hours).toBe(13); talk(world); expect(answer(world, 0)).toEqual(["Is Daylight Saving Time over?"]); expect(answer(world, 1)).toEqual([ "I put the clock back one hour.", "Just do what you can.", ]); finish(world); expect(world.state.clock.dst).toBe(false); expect(world.clock().hours).toBe(12); // Declining leaves the clock alone. talk(world); answer(world, 0); expect(answer(world, 0)).toEqual(["Just do what you can."]); finish(world); expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 }); }); it("will not change the clock within an hour of midnight", () => { const world = banking("2026-09-25T23:30:00"); talk(world); expect(answer(world, 0)).toEqual([ "Do you want to adjust your clock", "for Daylight Saving Time?", ]); expect(answer(world, 1)).toEqual([ "I lost the in- struction booklet for the POKéGEAR.", "Come back again in a while.", "Just do what you can.", ]); finish(world); expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 }); const dst = banking("2026-09-26T00:30:00"); dst.state.clock.dst = true; talk(dst); expect(answer(dst, 0)).toEqual([ "Do you want to adjust your clock", "for Daylight Saving Time?", ]); expect(answer(dst, 0)).toEqual(["Just do what you can."]); finish(dst); expect(dst.state.clock.dst).toBe(true); }); });