Files
Crystal/tests/start-menu.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 1af2beacdc
Checks / web (push) Canceled after 0s
Port the start menu, the party menu and the pack outside battle
Start opens the original start menu. POKéMON shows the party with the
Pokémon submenu (stats, switching, move order, held items); PACK shows the
four pockets with the field item effects for healing, PP and the Bicycle,
plus giving and tossing. The menu runs on a script draft, and its cursors
persist until a battle clears them. Select keeps the Key Items shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:10:22 +02:00

558 lines
19 KiB
TypeScript

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, maxPP } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
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",
PLAYERS_HOUSE = "24:6",
CYNDAQUIL = 155,
PIDGEY = 16;
const BICYCLE = 7,
ANTIDOTE = 9,
FULL_RESTORE = 0x0e,
POTION = 0x12,
RARE_CANDY = 0x20,
REVIVE = 0x27,
PP_UP = 0x3e,
ETHER = 0x3f,
ELIXER = 0x41,
ENERGYPOWDER = 0x79,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const CUT = 0x0f,
TACKLE = 0x21,
LEER = 0x2b,
SMOKESCREEN = 0x6c;
const POISONED = 0x08;
describe.skipIf(!existsSync(romPath))("the start menu", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** Standing in New Bark Town (outdoors) with two Pokémon and a pack. */
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => 0x42,
);
const data = world.assets.pokemon;
const mon = (species: number, level: number) =>
generatePartyMon(data, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)];
world.state.bag.items = [
{ item: POTION, quantity: 5 },
{ item: ANTIDOTE, quantity: 1 },
];
world.state.bag.keyItems = [BICYCLE];
world.state.scenes[NEW_BARK_TOWN] = 1;
world.restore({
...world.save(),
map,
x,
y,
direction: "down",
objects: [],
});
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 < 500; 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 menuItems(world: GameWorld) {
const choice = world.dialogue?.choice;
return choice?.kind === "menu" ? choice.items : [];
}
/** Choose the menu item matching `label` (the whole label, or a regex). */
function pick(world: GameWorld, label: string | RegExp) {
const items = menuItems(world);
const index = items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(index, `${label} in ${items.join(", ")}`).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
/** Choose the pack entry for `item` in its pocket. */
function pickItem(world: GameWorld, item: number) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack") throw new Error(`Expected the pack, got ${choice?.kind}`);
for (const [pocket, { entries }] of choice.pockets.entries()) {
const index = entries.findIndex((entry) => entry.item === item);
if (index >= 0) {
world.choosePackEntry(pocket, index);
return until(world);
}
}
throw new Error(`Item ${item} is not in the pack.`);
}
function open(world: GameWorld) {
expect(world.openStartMenu()).toBe(true);
return until(world);
}
/** B until every menu has closed. */
function close(world: GameWorld) {
for (let i = 0; i < 20 && world.busy; i++) {
if (world.dialogue?.choice) world.advanceDialogue();
until(world);
}
settle(world);
expect(world.diagnostic).toBe("");
}
it("lists the original items for the player's progress", () => {
const world = fixture();
open(world);
expect(menuItems(world)).toEqual([
"POKéMON",
"PACK",
"CHRIS",
"SAVE",
"OPTION",
"EXIT",
]);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details?.[1]).toBe("Contains items");
pick(world, "EXIT");
expect(world.busy).toBe(false);
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.flags.add(ENGINE_FLAGS.pokegear);
open(world);
expect(menuItems(world)).toEqual([
"POKéDEX",
"POKéMON",
"PACK",
"POKéGEAR",
"CHRIS",
"SAVE",
"OPTION",
"EXIT",
]);
// The cursor stays on the last choice.
pick(world, "POKéGEAR");
expect(world.notice).toBe("POKéGEAR is not ported yet.");
close(world);
open(world);
const reopened = world.dialogue?.choice;
expect(reopened?.kind === "menu" && reopened.items[reopened.selected]).toBe(
"POKéGEAR",
);
close(world);
});
it("opens only while the player stands still", () => {
const world = fixture();
world.tick("left");
expect(world.activity).toBe("walking");
expect(world.openStartMenu()).toBe(false);
});
it("shows the Pokémon submenu with field moves first", () => {
const world = fixture();
world.state.party[1].moves = [TACKLE, CUT, 0, 0];
open(world);
expect(pick(world, "POKéMON")).toEqual(["Choose a POKéMON."]);
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
pick(world, "CYNDAQUIL");
expect(menuItems(world)).toEqual(["STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
pick(world, "CANCEL");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["CUT", "STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
pick(world, "CUT");
expect(world.notice).toBe("Using CUT from the menu is not ported yet.");
// STATS shows the stats screen, and B returns to the party.
pick(world, "PIDGEY");
world.choose(menuItems(world).indexOf("STATS"));
expect(world.statsScreen?.species).toBe(PIDGEY);
world.advanceDialogue();
expect(until(world)).toEqual(["Choose a POKéMON."]);
expect(world.statsScreen).toBeUndefined();
close(world);
});
it("switches two Pokémon, and B cancels the move", () => {
const world = fixture();
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
expect(pick(world, "SWITCH")).toEqual(["Move to where?"]);
expect(menuItems(world)).toEqual(["▷CYNDAQUIL", "PIDGEY"]);
world.advanceDialogue();
until(world);
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY"]);
pick(world, "CYNDAQUIL");
pick(world, "SWITCH");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["PIDGEY", "CYNDAQUIL"]);
// Nothing commits until the menu closes.
expect(world.save().party?.[0].species).toBe(CYNDAQUIL);
close(world);
expect(world.state.party.map((mon) => mon.species)).toEqual([PIDGEY, CYNDAQUIL]);
// wPartyMenuCursor stays on the row chosen last, in the next menu too.
open(world);
pick(world, "POKéMON");
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.selected).toBe(1);
close(world);
});
it("reorders moves with their PP", () => {
const world = fixture();
const mon = world.state.party[0];
expect(mon.moves).toEqual([TACKLE, LEER, SMOKESCREEN, 0]);
mon.pp[0] = 30;
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
expect(pick(world, "MOVE")).toEqual(["CYNDAQUIL :L8"]);
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
expect(pick(world, "TACKLE")).toEqual(["Where?"]);
// B while choosing where cancels only the move being moved.
world.advanceDialogue();
until(world);
pick(world, "TACKLE");
pick(world, "SMOKESCREEN");
expect(menuItems(world)).toEqual(["SMOKESCREEN", "LEER", "TACKLE"]);
close(world);
expect(world.state.party[0].moves).toEqual([SMOKESCREEN, LEER, TACKLE, 0]);
expect(world.state.party[0].pp).toEqual([20, 30, 30, 0]);
});
it("gives, swaps and takes held items", () => {
const world = fixture();
world.state.bag.items.push({ item: LEFTOVERS, quantity: 1 });
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(menuItems(world)).toEqual(["GIVE", "TAKE"]);
expect(pick(world, "TAKE")).toEqual([
"CYNDAQUIL isn't holding anything.",
"Choose a POKéMON.",
]);
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
pick(world, "GIVE");
// Key items cannot be held; the pack stays open.
expect(pickItem(world, BICYCLE)).toEqual(["This item can't be held."]);
expect(pickItem(world, POTION)).toEqual([
"Made CYNDAQUIL hold POTION.",
"Choose a POKéMON.",
]);
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
pick(world, "GIVE");
expect(pickItem(world, LEFTOVERS)).toEqual([
"CYNDAQUIL is already holding",
"POTION. Switch items?",
]);
world.choose(1);
expect(until(world)).toEqual([
"Took CYNDAQUIL's POTION and",
"made it hold LEFTOVERS.",
"Choose a POKéMON.",
]);
close(world);
expect(world.state.party[0].item).toBe(LEFTOVERS);
expect(world.state.bag.items).toEqual([
{ item: POTION, quantity: 5 },
{ item: ANTIDOTE, quantity: 1 },
]);
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(pick(world, "TAKE")[0]).toBe("Took LEFTOVERS from CYNDAQUIL.");
close(world);
expect(world.state.party[0].item).toBe(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: LEFTOVERS, quantity: 1 });
});
it("keeps a held item when the pack is full", () => {
const world = fixture();
world.state.party[0].item = LEFTOVERS;
world.state.bag.items = Array.from({ length: 20 }, (_, i) => ({
item: [POTION, ANTIDOTE, BERRY][i % 3],
quantity: 99,
}));
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
expect(pick(world, "TAKE")[0]).toBe("Item storage space full.");
close(world);
expect(world.state.party[0].item).toBe(LEFTOVERS);
});
it("refuses to compose MAIL without changing anything", () => {
const world = fixture();
world.state.bag.items.push({ item: FLOWER_MAIL, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, FLOWER_MAIL);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
expect(pick(world, "GIVE")).toEqual(["To which POKéMON?"]);
pick(world, "CYNDAQUIL");
expect(world.notice).toBe("Writing MAIL is not ported yet.");
close(world);
expect(world.state.party[0].item).toBe(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: FLOWER_MAIL, quantity: 1 });
});
it("uses healing items from the pack on the party", () => {
const world = fixture();
const [cyndaquil, pidgey] = world.state.party;
cyndaquil.hp = 5;
pidgey.status = POISONED;
world.state.bag.items.push(
{ item: REVIVE, quantity: 1 },
{ item: FULL_RESTORE, quantity: 1 },
);
open(world);
pick(world, "PACK");
// The pack opens on the Items pocket, with the ROM's pocket names.
const pack = world.dialogue?.choice;
expect(
pack?.kind === "pack" && [pack.pocket, pack.pockets.map((pocket) => pocket.name)],
).toEqual([0, ["ITEM POCKET", "BALL POCKET", "KEY POCKET", "TM POCKET"]]);
pickItem(world, POTION);
expect(menuItems(world)).toEqual(["USE", "GIVE", "TOSS", "QUIT"]);
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
// Full HP: no effect, the Potion stays and the pack is shown again.
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
expect(world.dialogue?.choice?.kind).toBe("pack");
pickItem(world, POTION);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL recovered 20HP!"]);
pickItem(world, ANTIDOTE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY's cured of poison."]);
// A Revive on a Pokémon that has not fainted has no effect.
pickItem(world, REVIVE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["It won't have any effect."]);
close(world);
const [healed, cured] = world.state.party;
expect(healed.hp).toBe(25);
expect(cured.status).toBe(0);
expect(world.state.bag.items).toEqual([
{ item: POTION, quantity: 4 },
{ item: REVIVE, quantity: 1 },
{ item: FULL_RESTORE, quantity: 1 },
]);
world.state.party[1].hp = 0;
world.state.party[0].hp = 1;
world.state.party[0].status = POISONED;
open(world);
pick(world, "PACK");
pickItem(world, REVIVE);
pick(world, "USE");
expect(pick(world, "PIDGEY")).toEqual(["PIDGEY is revitalized."]);
pickItem(world, FULL_RESTORE);
pick(world, "USE");
const max = world.state.party[0].stats[0];
expect(pick(world, "CYNDAQUIL")).toEqual([`CYNDAQUIL recovered ${max - 1}HP!`]);
close(world);
expect(world.state.party[1].hp).toBe(world.state.party[1].stats[0] >> 1);
expect(world.state.party[0].hp).toBe(max);
expect(world.state.party[0].status).toBe(0);
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 4 }]);
});
it("makes bitter herbs cost happiness", () => {
const world = fixture();
world.state.party[0].hp = 1;
world.state.bag.items.push({ item: ENERGYPOWDER, quantity: 1 });
const happiness = world.state.party[0].happiness;
open(world);
pick(world, "PACK");
pickItem(world, ENERGYPOWDER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual([
"CYNDAQUIL recovered 24HP!",
"It looks bitter…",
]);
close(world);
expect(world.state.party[0].happiness).toBe(happiness - 5);
});
it("restores and raises PP", () => {
const world = fixture();
world.state.party[0].pp = [10, 30, 3, 0];
world.state.bag.items.push(
{ item: ETHER, quantity: 1 },
{ item: ELIXER, quantity: 1 },
{ item: PP_UP, quantity: 2 },
);
open(world);
pick(world, "PACK");
pickItem(world, ETHER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["Restore the PP of which move?"]);
expect(menuItems(world)).toEqual(["TACKLE", "LEER", "SMOKESCREEN"]);
// B returns to the party menu.
world.advanceDialogue();
expect(until(world)).toEqual(["Use on which POKéMON?"]);
pick(world, "CYNDAQUIL");
// Full PP: no effect.
expect(pick(world, "LEER")).toEqual(["It won't have any effect."]);
pickItem(world, ETHER);
pick(world, "USE");
pick(world, "CYNDAQUIL");
expect(pick(world, "TACKLE")).toEqual(["PP was restored."]);
pickItem(world, ELIXER);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["PP was restored."]);
pickItem(world, PP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["Raise the PP of which move?"]);
expect(pick(world, "TACKLE")).toEqual(["TACKLE's PP increased."]);
close(world);
// Tackle: 10, +10 (Ether), +10 (Elixer), then a PP Up adds a fifth of
// the base PP (7 of 35) to both the current PP and the maximum.
const pp = world.state.party[0].pp;
expect(pp).toEqual([0x40 | 37, 30, 13, 0]);
expect(maxPP(world.assets.pokemon, TACKLE, 1) & 63).toBe(42);
const items = world.state.bag.items;
expect(items.find((slot) => slot.item === PP_UP)?.quantity).toBe(1);
expect(items.some((slot) => slot.item === ETHER || slot.item === ELIXER)).toBe(false);
});
it("refuses a PP Up on a move that has had three", () => {
const world = fixture();
world.state.party[0].pp[1] = 0xc0 | 30;
world.state.bag.items.push({ item: PP_UP, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, PP_UP);
pick(world, "USE");
pick(world, "CYNDAQUIL");
// PPIsMaxedOutText, then the move list again.
expect(pick(world, "LEER")).toEqual([
"LEER's PP is maxed out.",
"Raise the PP of which move?",
]);
close(world);
expect(world.state.party[0].pp[1]).toBe(0xc0 | 30);
expect(world.state.bag.items.at(-1)).toEqual({ item: PP_UP, quantity: 1 });
});
it("tosses items after confirming the quantity", () => {
const world = fixture();
open(world);
pick(world, "PACK");
pickItem(world, POTION);
pick(world, "TOSS");
const choice = world.dialogue?.choice;
expect(choice?.kind === "martquantity" && [choice.prompt, choice.max]).toEqual([
"Throw away how\nmany?",
5,
]);
world.choose(3);
expect(until(world)).toEqual(["Throw away 3 POTION(S)?"]);
world.choose(0);
until(world);
pickItem(world, POTION);
pick(world, "TOSS");
world.choose(3);
until(world);
world.choose(1);
expect(until(world)).toEqual(["Threw away POTION(S)."]);
close(world);
expect(world.state.bag.items[0]).toEqual({ item: POTION, quantity: 2 });
});
it("gets on the Bicycle from the Key pocket, or gives Oak's warning indoors", () => {
const world = fixture();
open(world);
pick(world, "PACK");
pickItem(world, BICYCLE);
expect(menuItems(world)).toEqual(["USE", "SEL", "QUIT"]);
world.choose(0);
expect(settle(world).join(" ")).toContain("got on the\nBICYCLE.");
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.playerState).toBe(1);
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
open(indoors);
pick(indoors, "PACK");
pickItem(indoors, BICYCLE);
expect(pick(indoors, "USE")).toEqual([
"OAK: CHRIS! This isn't the time to use that!",
]);
close(indoors);
expect(indoors.state.playerState).toBe(0);
});
it("offers no USE without a field menu, and a notice for unported effects", () => {
const world = fixture();
world.state.bag.items.push(
{ item: BERRY, quantity: 1 },
{ item: RARE_CANDY, quantity: 1 },
);
world.state.bag.balls = [{ item: 5, quantity: 3 }];
open(world);
pick(world, "PACK");
// Poké Balls have no field menu: GIVE and TOSS only.
pickItem(world, 5);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
pick(world, "QUIT");
pickItem(world, RARE_CANDY);
pick(world, "USE");
expect(world.notice).toBe("Using RARE CANDY is not ported yet.");
close(world);
expect(world.state.bag.items.find((slot) => slot.item === RARE_CANDY)).toBeTruthy();
});
it("saves the state from before the menu while it is open", () => {
const world = fixture();
world.state.party[0].hp = 5;
open(world);
pick(world, "PACK");
pickItem(world, POTION);
pick(world, "USE");
pick(world, "CYNDAQUIL");
const saved = world.save();
expect(saved.party?.[0].hp).toBe(5);
expect(saved.bag?.items[0].quantity).toBe(5);
const reloaded = fixture();
reloaded.restore(saved);
expect(reloaded.busy).toBe(false);
expect(reloaded.state.party[0].hp).toBe(5);
close(world);
expect(world.state.party[0].hp).toBe(25);
});
});