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>
This commit is contained in:
1 parent
47698565ab
commit
c4cc0e756c
19 files changed
+732
-21
No files matched your search
@@ -183,6 +183,18 @@ describe.skipIf(!existsSync(romPath))("switching Pokémon in battle", () => {
|
||||
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
|
||||
});
|
||||
|
||||
it("makes no offer in Set mode", () => {
|
||||
const battle = new Battle({
|
||||
...options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
|
||||
shift: false,
|
||||
});
|
||||
const { texts } = play(battle, {});
|
||||
expect(texts).toContain("YOUNGSTER MIKEY sent out RATTATA!");
|
||||
expect(texts.some((text) => text.includes("is about to use"))).toBe(false);
|
||||
expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false);
|
||||
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
|
||||
});
|
||||
|
||||
it("keeps the Pokémon in when the offer is declined", () => {
|
||||
const battle = new Battle(
|
||||
options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
|
||||
|
||||
@@ -388,6 +388,49 @@ test("the trainer card turns to the badges and back", async ({ page }) => {
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
});
|
||||
|
||||
test("the options change the battle style and the menu account", async ({
|
||||
page,
|
||||
}) => {
|
||||
await start(page, "pokegear");
|
||||
const choices = page.locator("#dialogue-choice"),
|
||||
screen = page.locator("#options"),
|
||||
list = page.locator("#options-list");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(choices).toContainText("Party Pokémon status");
|
||||
await choices.getByRole("option", { name: /^OPTION/ }).click();
|
||||
await expect(screen).toBeVisible();
|
||||
await expect(page.locator("#native-dialogue")).toBeHidden();
|
||||
await expect(list.locator('[aria-current="true"]')).toContainText(
|
||||
"TEXT SPEED",
|
||||
);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(list.locator('[aria-current="true"]')).toHaveText(
|
||||
"BATTLE STYLESET",
|
||||
);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
await expect(list.locator('[aria-current="true"]')).toHaveText(
|
||||
"MENU ACCOUNTOFF",
|
||||
);
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(screen).toBeHidden();
|
||||
await expect(choices).not.toContainText("Party Pokémon status");
|
||||
await choices.getByRole("option", { name: /^EXIT/ }).click();
|
||||
await page.clock.runFor(500);
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
const { state } = await backup(page);
|
||||
expect(state.options).toEqual({
|
||||
options: 3 | (1 << 6),
|
||||
textboxFrame: 0,
|
||||
printerBrightness: 0x40,
|
||||
options2: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("Mom keeps the player's money with the digit box", async ({ page }) => {
|
||||
await start(page, "mom");
|
||||
const text = page.locator("#dialogue-text"),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user