Files
Crystal/tests/options.test.ts
Sébastien QUEROLandClaude Opus 5.5 c4cc0e756c
Checks / web (push) Canceled after 0s
Port the options screen
OPTION in the start menu runs _Option with the ROM's labels and settings,
OptionsControl's cursor wrapping and each option's bit handling. The four
option bytes live in the script state and saves, defaulting to
DefaultOptions. The battle style now decides whether a trainer's next
Pokémon brings an offer to switch, and the menu account whether the start
menu describes its items; the other settings are kept for later.

Closes #24

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:47:11 +02:00

201 lines
6.3 KiB
TypeScript

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/);
});
});