import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld, migrateSave } from "../src/game/world/world"; import { generatePartyMon } from "../src/game/party/pokemon"; import { OW } from "../src/game/world/constants"; import { defaultOptions, type OptionsView, } from "../src/game/scripts/options"; 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", CYNDAQUIL = 155; describe.skipIf(!existsSync(romPath))("the options screen", () => { const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath))); function fixture() { const world = new GameWorld( assets(), () => new Date("2026-09-25T12:00:00"), () => 0x42, ); world.state.party = [ generatePartyMon(world.assets.pokemon, { species: CYNDAQUIL, level: 8, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x9abc, caughtData: 0, }), ]; world.restore({ ...world.save(), map: NEW_BARK_TOWN, x: 13, y: 7, direction: "down", objects: [], }); return world; } function startMenu(world: GameWorld) { const choice = world.dialogue?.choice; if (choice?.kind !== "menu") throw new Error("Expected the start menu."); return choice; } function open(world: GameWorld) { expect(world.openStartMenu()).toBe(true); world.choose(startMenu(world).items.indexOf("OPTION")); return view(world); } function view(world: GameWorld): OptionsView { const choice = world.dialogue?.choice; if (choice?.kind !== "options") throw new Error(`Expected the options, got ${choice?.kind}`); return choice.view; } const values = (world: GameWorld) => view(world).rows.map((row) => row.value); function press( world: GameWorld, ...buttons: ("a" | "b" | "up" | "down" | "left" | "right" | "start")[] ) { for (const button of buttons) { if (button === "a") world.interact(); else if (button === "b") world.advanceDialogue(); else if (button === "start") world.choose("start"); else world.moveCursor(button); } } /** Leave the options and the start menu, committing the draft. */ function close(world: GameWorld) { press(world, "b"); startMenu(world); settle(world, [-1]); expect(world.busy).toBe(false); expect(world.diagnostic).toBe(""); } it("lists the ROM's options with the default settings", () => { const world = fixture(); const options = open(world); expect(options.rows.map((row) => row.label)).toEqual([ "TEXT SPEED", "BATTLE SCENE", "BATTLE STYLE", "SOUND", "PRINT", "MENU ACCOUNT", "FRAME", "CANCEL", ]); expect(values(world)).toEqual([ "MID", "ON", "SHIFT", "MONO", "NORMAL", "ON", "TYPE 1", "", ]); expect(options.cursor).toBe(0); }); it("wraps the cursor and cycles each setting both ways", () => { const world = fixture(); open(world); press(world, "up"); expect(view(world).cursor).toBe(7); press(world, "down"); expect(view(world).cursor).toBe(0); // Text speed: MID, SLOW, FAST, MID to the right. const speeds = []; for (let i = 0; i < 3; i++) { press(world, "right"); speeds.push(values(world)[0]); } expect(speeds).toEqual(["SLOW", "FAST", "MID"]); press(world, "left"); expect(values(world)[0]).toBe("FAST"); // Battle scene, style and sound toggle with either direction. press(world, "down", "right", "down", "left", "down", "right"); expect(values(world).slice(1, 4)).toEqual(["OFF", "SET", "STEREO"]); // Printing: NORMAL, then left to LIGHTER, LIGHTEST and round to DARKEST. press(world, "down", "left", "left", "left"); expect(values(world)[4]).toBe("DARKEST"); press(world, "down", "right"); expect(values(world)[5]).toBe("OFF"); // Frames wrap from 1 to 8. press(world, "down", "left"); expect(values(world)[6]).toBe("TYPE 8"); press(world, "right", "right"); expect(values(world)[6]).toBe("TYPE 2"); // A only acts on CANCEL. press(world, "a"); expect(view(world).cursor).toBe(6); press(world, "down", "a"); startMenu(world); settle(world, [-1]); expect(world.state.options).toEqual({ options: OW.TEXT_DELAY_FAST | (1 << OW.STEREO) | (1 << OW.BATTLE_SHIFT) | (1 << OW.BATTLE_SCENE), textboxFrame: 1, printerBrightness: OW.GBPRINTER_DARKEST, options2: 0, }); }); it("leaves with Start and keeps the text speed's high bits only", () => { const world = fixture(); // Bit 3 is cleared by Options_TextSpeed's .Save; bit 4 survives. world.state.options.options = 0b0001_1011; open(world); expect(values(world)[0]).toBe("MID"); press(world, "right", "start"); startMenu(world); settle(world, [-1]); expect(world.state.options.options).toBe(0b0001_0101); }); it("hides the start menu's descriptions without the menu account", () => { const world = fixture(); open(world); expect(world.dialogue).toBeDefined(); press(world, "up", "up", "up", "right", "b"); expect(startMenu(world).details).toBeUndefined(); settle(world, [-1]); expect(world.openStartMenu()).toBe(true); expect(startMenu(world).details).toBeUndefined(); settle(world, [-1]); }); it("commits with the start menu and saves the options", () => { const world = fixture(); open(world); press(world, "down", "down", "right"); // A save while the menu is open keeps the earlier options. expect(world.save().options).toEqual(defaultOptions()); close(world); const save = world.save(); expect(save.options.options).toBe(OW.TEXT_DELAY_MED | (1 << OW.BATTLE_SHIFT)); const other = fixture(); other.restore(save); expect(other.state.options).toEqual(save.options); const { options: _, ...old } = save; expect(migrateSave(old, () => 0)?.options).toEqual(defaultOptions()); expect(() => other.restore({ ...save, options: { ...save.options, textboxFrame: 8 } }), ).toThrow(/Invalid native save/); }); });