Files
Crystal/tests/start-menu.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 e2dc5ba520 Move items with Select and browse the party's moves
In the start menu's pack, Select marks an item and asks where it should
go; A or Select on another row moves it there, merging stacks of the
same item unless either holds 99, and B ends the move. The packs that
GIVE and the PC open ignore Select, as in the original.

Left and right on the MOVE screen show the previous or next party
member's moves, skipping Eggs, unless a move is being moved.

Part of #27

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

998 lines
34 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 { calcStats, 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,
QUILAVA = 156,
PIDGEY = 16,
PIKACHU = 25,
RAICHU = 26;
const BICYCLE = 7,
COIN_CASE = 0x36,
ANTIDOTE = 9,
FULL_RESTORE = 0x0e,
POTION = 0x12,
THUNDERSTONE = 0x17,
HP_UP = 0x1a,
RARE_CANDY = 0x20,
REVIVE = 0x27,
PP_UP = 0x3e,
ETHER = 0x3f,
ELIXER = 0x41,
EVERSTONE = 0x70,
ENERGYPOWDER = 0x79,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const SOFTBOILED = 0x87;
const CUT = 0x0f,
GUST = 0x10,
SAND_ATTACK = 0x1c,
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; B leaves the Pokégear's clock.
expect(pick(world, "POKéGEAR")).toEqual(["Press any button to exit."]);
expect(world.dialogue?.choice?.kind).toBe("pokegear");
world.advanceDialogue();
until(world);
expect(world.dialogue?.choice?.kind).toBe("menu");
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"]);
// CutFunction's CheckBadge, then the party menu again.
expect(pick(world, "CUT")).toEqual([
"Sorry! A new BADGE is required.",
"Choose a POKéMON.",
]);
// 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("shares HP with Softboiled, which needs more than a fifth of it", () => {
const world = fixture();
const data = world.assets.pokemon;
const chansey = generatePartyMon(data, {
species: 113,
level: 20,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
chansey.moves = [SOFTBOILED, 0, 0, 0];
world.state.party.push(chansey);
world.state.party[0].hp = 3;
const fifth = Math.floor(chansey.stats[0] / 5);
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual(["Use on which POKéMON?"]);
// Not on itself, and not on a Pokémon at full HP.
expect(pick(world, "CHANSEY")).toEqual([
"That can't be used on this POKéMON.",
"Use on which POKéMON?",
]);
expect(pick(world, "PIDGEY")[0]).toBe("That can't be used on this POKéMON.");
expect(pick(world, "CYNDAQUIL")).toEqual([
`CYNDAQUIL recovered ${Math.min(fifth, world.liveState.party[0].stats[0] - 3)}HP!`,
"Choose a POKéMON.",
]);
// The cursor returns to the user.
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.selected).toBe(2);
close(world);
expect(world.state.party[2].hp).toBe(chansey.stats[0] - fifth);
expect(world.state.party[0].hp).toBe(
Math.min(world.state.party[0].stats[0], 3 + fifth),
);
world.state.party[2].hp = fifth;
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual([
"Not enough HP!",
"Choose a POKéMON.",
]);
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("shows the other party members' moves with left and right, skipping Eggs", () => {
const world = fixture();
const party = world.state.party;
party.push({ ...structuredClone(party[1]), species: 0xfd }); // EGG
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
expect(pick(world, "MOVE")).toEqual(["CYNDAQUIL :L8"]);
const heading = (direction: "left" | "right") => {
world.moveCursor(direction);
return until(world)[0];
};
expect(heading("right")).toBe("PIDGEY :L4");
expect(menuItems(world)).toEqual(["TACKLE"]);
// Past the last Pokémon that is not an Egg, .cycle_right comes back.
expect(heading("right")).toBe("PIDGEY :L4");
expect(heading("left")).toBe("CYNDAQUIL :L8");
expect(heading("left")).toBe("CYNDAQUIL :L8");
// Not while a move is being moved.
pick(world, "LEER");
world.moveCursor("right");
expect(world.dialogue?.pages[0]).toBe("Where?");
world.advanceDialogue();
until(world);
expect(heading("right")).toBe("PIDGEY :L4");
// B returns to the party menu, on the Pokémon shown last.
world.advanceDialogue();
until(world);
const menu = world.dialogue?.choice;
expect(menu?.kind === "menu" && menu.items[menu.selected]).toBe("PIDGEY");
close(world);
});
it("moves items within a pocket with Select, merging stacks of the same item", () => {
const world = fixture();
world.state.bag.items = [
{ item: POTION, quantity: 5 },
{ item: ANTIDOTE, quantity: 1 },
{ item: ETHER, quantity: 1 },
{ item: POTION, quantity: 98 },
];
world.state.bag.keyItems = [BICYCLE, COIN_CASE];
open(world);
pick(world, "PACK");
const pack = () => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "pack") throw new Error(`Expected the pack, got ${choice?.kind}`);
return choice;
};
const items = () => world.state.bag.items.map((slot) => [slot.item, slot.quantity]);
// Select marks the Potions and asks where; the pocket stays put.
world.useSelect();
until(world);
expect([pack().switching, pack().prompt]).toEqual([
0,
"Where should this\nbe moved to?",
]);
world.moveCursor("right");
expect(pack().pocket).toBe(0);
// A (or Select) on another row moves them there.
world.choosePackEntry(0, 2);
until(world);
expect(pack().switching).toBeUndefined();
expect(pack().selected).toBe(2);
// Nothing commits until the menu closes.
expect(world.save().bag.items[0].item).toBe(POTION);
world.useSelect();
until(world);
world.choosePackEntry(0, 3, true);
until(world);
// 5 + 98: the destination fills to 99 and 4 stay behind.
world.useSelect();
until(world);
world.advanceDialogue(); // B ends the move, still in the pack
until(world);
expect([pack().kind, pack().switching]).toEqual(["pack", undefined]);
world.choosePackEntry(0, 1, true);
until(world);
world.choosePackEntry(0, 1, true); // onto itself: nothing moves
until(world);
world.moveCursor("right");
world.moveCursor("right");
world.choosePackEntry(2, 1, true);
until(world);
world.choosePackEntry(2, 0, true);
until(world);
close(world);
expect(items()).toEqual([
[ANTIDOTE, 1],
[ETHER, 1],
[POTION, 4],
[POTION, 99],
]);
expect(world.state.bag.keyItems).toEqual([COIN_CASE, BICYCLE]);
// Two stacks that fit merge into one; a full stack never merges.
world.state.bag.items = [
{ item: POTION, quantity: 2 },
{ item: POTION, quantity: 3 },
{ item: ETHER, quantity: 99 },
{ item: ETHER, quantity: 1 },
];
open(world);
pick(world, "PACK");
world.choosePackEntry(0, 0, true);
until(world);
world.choosePackEntry(0, 1, true);
until(world);
world.choosePackEntry(0, 2, true);
until(world);
world.choosePackEntry(0, 1, true);
until(world);
close(world);
expect(items()).toEqual([
[POTION, 5],
[ETHER, 1],
[ETHER, 99],
]);
});
it("does not move items in the pack that GIVE opens", () => {
const world = fixture();
open(world);
pick(world, "POKéMON");
pick(world, "CYNDAQUIL");
pick(world, "ITEM");
pick(world, "GIVE");
world.useSelect();
until(world);
const choice = world.dialogue?.choice;
expect(choice?.kind).toBe("pack");
expect(choice?.kind === "pack" && choice.switching).toBeUndefined();
close(world);
expect(world.state.bag.items.map((slot) => slot.item)).toEqual([POTION, ANTIDOTE]);
});
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("registers the Bicycle with SEL, and Select rides it without a message", () => {
const world = fixture();
// Nothing registered yet.
expect(world.useSelect()).toBe(true);
expect(until(world)).toEqual([
"An item in your PACK may be",
"registered for use on SELECT Button.",
]);
close(world);
open(world);
pick(world, "PACK");
pickItem(world, BICYCLE);
expect(pick(world, "SEL")).toEqual(["Registered the BICYCLE."]);
close(world);
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
// The registration is saved.
world.restore(world.save());
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.diagnostic).toBe("");
expect(world.state.playerState).toBe(1);
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.state.playerState).toBe(0);
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
indoors.state.registeredItem = { which: 0x81, item: BICYCLE };
indoors.useSelect();
expect(until(indoors)).toEqual(["OAK: CHRIS! This isn't the time to use that!"]);
close(indoors);
expect(indoors.state.playerState).toBe(0);
});
it("forgets a registered item that left its slot", () => {
const world = fixture();
world.state.registeredItem = { which: 0x02, item: POTION };
world.useSelect();
expect(until(world)[0]).toBe("An item in your PACK may be");
close(world);
expect(world.state.registeredItem).toEqual({ which: 0, item: 0 });
});
it("offers no USE without a field menu", () => {
const world = fixture();
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");
close(world);
});
it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => {
const world = fixture();
const pidgey = world.state.party[1];
pidgey.moves = [TACKLE, CUT, LEER, GUST];
pidgey.pp = [35, 30, 30, 35];
pidgey.hp = 0;
world.state.bag.items.push({ item: RARE_CANDY, quantity: 2 });
const data = world.assets.pokemon;
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
const pages = pick(world, "PIDGEY");
expect(pages.slice(0, 2)).toEqual([
"PIDGEY grew to level 5!",
pages[1],
]);
expect(pages[1]).toMatch(/^ATTACK \d+ DEFENSE \d+ SPCL\.ATK \d+ SPCL\.DEF \d+ SPEED \d+$/);
expect(pages.slice(2)).toEqual([
"PIDGEY is trying to learn SAND-ATTACK.",
"But PIDGEY can't learn more than four moves.",
"Delete an older move to make room for SAND-ATTACK?",
]);
world.choose(1);
expect(until(world)).toEqual(["Which move should be forgotten?"]);
expect(menuItems(world)).toEqual(["TACKLE", "CUT", "LEER", "GUST"]);
expect(pick(world, "CUT")).toEqual([
"HM moves can't be forgotten now.",
"Which move should be forgotten?",
]);
expect(pick(world, "LEER")).toEqual([
"1, 2 and… Poof!",
"PIDGEY forgot LEER.",
"And…",
"PIDGEY learned SAND-ATTACK!",
]);
close(world);
const mon = world.state.party[1];
expect(mon.level).toBe(5);
expect(mon.exp).toBe(data.expAtLevel(data.baseData(PIDGEY).growthRate, 5));
expect(mon.moves).toEqual([TACKLE, CUT, SAND_ATTACK, GUST]);
expect(mon.pp[2]).toBe(data.move(SAND_ATTACK).pp);
// The Rare Candy adds the maximum HP's rise even to a fainted Pokémon.
expect(mon.hp).toBe(mon.stats[0] - pidgey.stats[0]);
expect(mon.hp).toBeGreaterThan(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: RARE_CANDY, quantity: 1 });
});
it("can decline to learn the move", () => {
const world = fixture();
world.state.party[1].moves = [TACKLE, CUT, LEER, GUST];
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
pick(world, "PIDGEY");
world.choose(0);
expect(until(world)).toEqual(["Stop learning SAND-ATTACK?"]);
world.choose(1);
expect(until(world)).toEqual(["PIDGEY did not learn SAND-ATTACK."]);
close(world);
expect(world.state.party[1].moves).toEqual([TACKLE, CUT, LEER, GUST]);
expect(world.state.party[1].level).toBe(5);
});
it("evolves after a Rare Candy, and B can stop it", () => {
for (const stop of [false, true]) {
const world = fixture();
const data = world.assets.pokemon;
world.state.party[0] = generatePartyMon(data, {
species: CYNDAQUIL,
level: 13,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
world.choose(0);
// Acknowledge pages until the evolution animation starts.
const pages: string[] = [];
for (let i = 0; i < 200 && !world.evolution; i++) {
const dialogue = world.dialogue;
if (dialogue) {
pages.push(dialogue.pages[dialogue.index].replace(/\n/g, " "));
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else world.tick();
}
expect(pages[0]).toBe("CYNDAQUIL grew to level 14!");
expect(pages.at(-1)).toBe("What? CYNDAQUIL is evolving!");
expect(world.picture).toBe(CYNDAQUIL);
// B does nothing before the flashes begin.
world.advanceDialogue();
expect(world.evolution).toBeDefined();
for (let tick = 0; tick < 40; tick++) world.tick();
if (stop) {
world.advanceDialogue();
expect(until(world)).toEqual(["Huh? CYNDAQUIL stopped evolving!"]);
} else {
expect(until(world)).toEqual([
"Congratulations! Your CYNDAQUIL",
"evolved into QUILAVA!",
]);
}
close(world);
const mon = world.state.party[0];
expect(mon.level).toBe(14);
expect([mon.species, mon.nickname]).toEqual(
stop ? [CYNDAQUIL, "CYNDAQUIL"] : [QUILAVA, "QUILAVA"],
);
expect(mon.stats).toEqual(
calcStats(data, mon.species, mon.dvs, 14, mon.statExp),
);
expect(world.state.pokedex.caught.has(QUILAVA)).toBe(!stop);
expect(world.state.bag.items.some((slot) => slot.item === RARE_CANDY)).toBe(false);
}
});
it("evolves with a stone, which B cannot stop, unless held back by an Everstone", () => {
const world = fixture();
const data = world.assets.pokemon;
world.state.party[1] = generatePartyMon(data, {
species: PIKACHU,
level: 10,
item: EVERSTONE,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.party[1].nickname = "SPARKY";
world.state.bag.items.push({ item: THUNDERSTONE, quantity: 2 });
open(world);
pick(world, "PACK");
pickItem(world, THUNDERSTONE);
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.details).toEqual(["NOT ABLE", "ABLE"]);
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
pickItem(world, THUNDERSTONE);
pick(world, "USE");
expect(pick(world, "SPARKY")).toEqual(["It won't have any effect."]);
close(world);
world.state.party[1].item = 0;
open(world);
pick(world, "PACK");
pickItem(world, THUNDERSTONE);
pick(world, "USE");
world.choose(1);
for (let i = 0; i < 20 && !world.evolution; i++) {
if (world.dialogue?.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
for (let tick = 0; tick < 60; tick++) world.tick();
world.advanceDialogue();
expect(until(world)).toEqual([
"Congratulations! Your SPARKY",
"evolved into RAICHU!",
]);
close(world);
const mon = world.state.party[1];
// A nickname stays.
expect([mon.species, mon.nickname]).toEqual([RAICHU, "SPARKY"]);
expect(world.state.bag.items.at(-1)).toEqual({ item: THUNDERSTONE, quantity: 1 });
});
it("raises stat experience with a vitamin, up to its limit", () => {
const world = fixture();
const data = world.assets.pokemon;
const mon = world.state.party[0];
const hp = mon.hp,
happiness = mon.happiness;
world.state.bag.items.push({ item: HP_UP, quantity: 2 });
open(world);
pick(world, "PACK");
pickItem(world, HP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL's HEALTH rose."]);
close(world);
const raised = world.state.party[0];
expect(raised.statExp[0]).toBe(2560);
expect(raised.stats).toEqual(calcStats(data, CYNDAQUIL, raised.dvs, 8, raised.statExp));
expect(raised.hp).toBe(hp);
expect(raised.happiness).toBeGreaterThan(happiness);
raised.statExp[0] = 100 << 8;
open(world);
pick(world, "PACK");
pickItem(world, HP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
close(world);
expect(world.state.party[0].statExp[0]).toBe(100 << 8);
expect(world.state.bag.items.at(-1)).toEqual({ item: HP_UP, quantity: 1 });
});
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);
});
});