Files
Crystal/tests/pc.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 3456aa4e11
Checks / web (push) Canceled after 0s
Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Field moves chosen in the party menu run their original checks and the
ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall,
Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key
item that Select then uses, replacing the Key Items shortcut, and Bill's
PC moves Pokémon between the party and every box.

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

661 lines
22 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,
validPartyMon,
} from "../src/game/party/pokemon";
import {
MAX_PC_ITEMS,
emptyStorage,
receivePCItem,
toBoxMon,
validStorage,
} from "../src/game/party/storage";
import { removeFromSlot } from "../src/game/party/bag";
import { C } from "../src/game/battle/constants";
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 POKEMON_CENTER = "26:5",
CYNDAQUIL = 155,
PIDGEY = 16,
POTION = 18,
MYSTERY_EGG = 69;
describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** In front of Cherrygrove's PC (COLL_PC at 9,1). */
function fixture() {
let seed = 5;
const world = new GameWorld(
assets(),
() => new Date("2026-09-25T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
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 }];
world.restore({
...world.save(),
map: POKEMON_CENTER,
x: 9,
y: 2,
direction: "up",
objects: [],
});
return world;
}
/** Acknowledge pages and timed messages 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 (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.");
}
/** Choose the menu item matching `label` (the whole label, or a regex). */
function pick(world: GameWorld, label: string | RegExp) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu")
throw new Error(`Expected a menu, got ${choice?.kind}`);
const index = choice.items.findIndex((item) =>
typeof label === "string" ? item === label : label.test(item),
);
expect(
index,
`${label} in ${choice.items.join(", ")}`,
).toBeGreaterThanOrEqual(0);
world.choose(index);
return until(world);
}
function menuItems(world: GameWorld) {
const choice = world.dialogue?.choice;
return choice?.kind === "menu" ? choice.items : [];
}
function open(world: GameWorld) {
world.interact();
expect(world.notice, world.diagnostic).toBe("");
return until(world);
}
/** Leave every menu with B until the script ends. */
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.notice, world.diagnostic).toBe("");
}
it("lists Bill's PC and the player's PC, and Oak's PC after the Pokédex", () => {
const world = fixture();
const pages = open(world);
expect(pages).toEqual([
`${world.name} turned on the PC.`,
"Access whose PC?",
]);
expect(menuItems(world)).toEqual([
"BILL's PC",
`${world.name}'s PC`,
"TURN OFF",
]);
expect(pick(world, "TURN OFF")).toContain("… Link closed…");
settle(world);
world.state.flags.add(ENGINE_FLAGS.pokedex);
open(world);
expect(menuItems(world)).toEqual([
"BILL's PC",
`${world.name}'s PC`,
"PROF.OAK's PC",
"TURN OFF",
]);
});
it("refuses to start without a Pokémon", () => {
const world = fixture();
world.state.party = [];
world.interact();
expect(settle(world).join(" ").replace(/\n/g, " ")).toContain(
"Bzzzzt! You must have a POKéMON to use this!",
);
expect(world.notice, world.diagnostic).toBe("");
});
it("deposits, keeps the last Pokémon and withdraws with recalculated stats", () => {
const world = fixture();
const data = world.assets.pokemon;
const pidgey = world.state.party[1];
pidgey.hp = 1;
pidgey.status = 0x08;
pidgey.pp = [0, 0, 0, 0];
pidgey.statExp = [0xffff, 0xffff, 0xffff, 0xffff, 0xffff];
open(world);
expect(pick(world, "BILL's PC")).toEqual([
"BILL's PC accessed.",
"POKéMON Storage System opened.",
"What?",
]);
expect(menuItems(world)).toEqual([
"WITHDRAW POKéMON",
"DEPOSIT POKéMON",
"CHANGE BOX",
"MOVE POKéMON W/O MAIL",
"SEE YA!",
]);
pick(world, /^DEPOSIT/);
expect(world.dialogue?.pages[0]).toBe("PARTY POKéMON\nChoose a POKéMON.");
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY", "CANCEL"]);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details).toEqual([
":L8",
":L4",
"",
]);
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
expect(pick(world, "DEPOSIT")).toContain("Stored PIDGEY!");
// A mid-PC save holds the committed game.
expect(world.save().party).toHaveLength(2);
expect(world.liveState.party).toHaveLength(1);
const [stored] = world.liveState.storage.boxes[0];
expect(stored).not.toHaveProperty("hp");
// RestorePPOfDepositedPokemon restores PP; status and HP are dropped.
expect(stored.pp).toEqual(
stored.moves.map((move) => (move ? data.move(move).pp : 0)),
);
// BillsPC_CheckMail_PreventBlackout keeps the last Pokémon.
expect(menuItems(world)).toEqual(["CYNDAQUIL", "CANCEL"]);
pick(world, "CYNDAQUIL");
expect(pick(world, "DEPOSIT")).toContain("It's your last POKéMON!");
expect(pick(world, "CANCEL")).toContain("What?");
pick(world, /^WITHDRAW/);
expect(world.dialogue?.pages[0]).toBe("BOX1\nChoose a POKéMON.");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual([
"WITHDRAW",
"STATS",
"RELEASE",
"CANCEL",
]);
expect(pick(world, "WITHDRAW")).toContain("Got PIDGEY!");
pick(world, "CANCEL");
close(world);
const back = world.state.party[1];
expect(world.state.storage.boxes[0]).toEqual([]);
// SendGetMonIntoFromBox: CalcMonStats with stat experience, full HP.
expect(back.status).toBe(0);
expect(back.hp).toBe(back.stats[0]);
expect(back.stats).toEqual(
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
);
expect(back.stats[0]).toBeGreaterThan(pidgey.stats[0]);
expect(validPartyMon(data, back)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("moves Pokémon between the party and boxes without mail", () => {
const world = fixture();
const data = world.assets.pokemon;
const [cyndaquil, pidgey] = world.state.party;
pidgey.hp = 1;
pidgey.pp = [1, 0, 0, 0];
const boxed = toBoxMon(
generatePartyMon(data, {
species: 19, // RATTATA
level: 6,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x1234,
caughtData: 0,
}),
);
world.state.storage.boxes[0] = [boxed];
open(world);
pick(world, "BILL's PC");
expect(pick(world, "MOVE POKéMON W/O MAIL")).toEqual([
"Each time you move a POKéMON, data will be saved. OK?",
]);
world.choose(1);
until(world);
// The party, then the fourteen boxes, with the current box highlighted.
const boxes = world.dialogue?.choice;
expect(boxes?.kind === "menu" && [boxes.items.length, boxes.selected]).toEqual([15, 1]);
expect(menuItems(world)[0]).toBe("PARTY POKéMON");
// PIDGEY from the party to the end of BOX1: its PP are restored.
pick(world, "PARTY POKéMON");
pick(world, /^PIDGEY/);
expect(menuItems(world)).toEqual(["MOVE", "STATS", "CANCEL"]);
pick(world, "MOVE");
pick(world, "BOX1");
expect(menuItems(world)).toEqual(["RATTATA", "▶"]);
pick(world, "▶");
// RATTATA from BOX1 to the front of the party, with full HP.
pick(world, "BOX1");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, /^CYNDAQUIL/);
expect(world.liveState.party.map((mon) => mon.species)).toEqual([19, 155]);
// Within the party: RATTATA to the end.
pick(world, "PARTY POKéMON");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, "▶");
close(world);
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 19]);
const rattata = world.state.party[1];
expect(rattata.hp).toBe(rattata.stats[0]);
expect(rattata.stats).toEqual(calcStats(data, 19, 0x1234, 6, rattata.statExp));
expect(world.state.storage.boxes[0].map((mon) => mon.species)).toEqual([16]);
expect(world.state.storage.boxes[0][0].pp[0]).toBe(data.move(pidgey.moves[0]).pp);
expect(cyndaquil.species).toBe(155);
});
it("keeps the last Pokémon and refuses a full destination when moving", () => {
const world = fixture();
const data = world.assets.pokemon;
world.state.party = [world.state.party[0]];
const full = Array.from({ length: 20 }, () =>
toBoxMon(
generatePartyMon(data, {
species: 16,
level: 3,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
}),
),
);
world.state.storage.boxes[1] = full;
world.state.storage.boxes[2] = [structuredClone(full[0])];
world.state.storage.boxes[2][0].nickname = "SPARE";
open(world);
pick(world, "BILL's PC");
pick(world, "MOVE POKéMON W/O MAIL");
world.choose(1);
until(world);
pick(world, "PARTY POKéMON");
expect(pick(world, /^CYNDAQUIL/)).toEqual(["What's up?"]);
expect(pick(world, "MOVE")).toContain("It's your last POKéMON!");
// BOX3 into the full BOX2: no room, and the position is chosen again.
pick(world, "BOX3");
pick(world, /^SPARE/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("There's no room!");
// B leaves the destination list for the list of boxes to move from.
world.advanceDialogue();
until(world);
// Within the full BOX2 a Pokémon can still move.
pick(world, "BOX2");
pick(world, /^PIDGEY/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("Saving… Leave ON!");
close(world);
expect(world.state.storage.boxes[1]).toHaveLength(20);
expect(world.state.storage.boxes[2].map((mon) => mon.nickname)).toEqual(["SPARE"]);
expect(world.state.party).toHaveLength(1);
});
it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => {
const world = fixture();
const data = world.assets.pokemon;
const pidgey = world.state.party[1];
pidgey.hp = 1;
pidgey.status = 0x08;
open(world);
pick(world, "BILL's PC");
pick(world, /^DEPOSIT/);
pick(world, "PIDGEY");
const stats = () => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected a menu");
world.choose(choice.items.indexOf("STATS"));
for (let tick = 0; tick < 10 && !world.statsScreen; tick++) world.tick();
const shown = world.statsScreen;
world.advanceDialogue();
until(world);
return shown;
};
// A party Pokémon shows its own HP and status.
expect(stats()).toMatchObject({ species: PIDGEY, hp: 1, status: 0x08 });
expect(world.statsScreen).toBeUndefined();
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
pick(world, "DEPOSIT");
pick(world, "CANCEL");
pick(world, /^WITHDRAW/);
pick(world, "PIDGEY");
// CalcTempmonStats: a boxed Pokémon shows computed stats, full HP and
// no status.
const boxed = stats()!;
expect(boxed.status).toBe(0);
expect(boxed.hp).toBe(boxed.stats[0]);
expect(boxed.stats).toEqual(
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
);
expect(menuItems(world)).toEqual(["WITHDRAW", "STATS", "RELEASE", "CANCEL"]);
expect(world.notice, world.diagnostic).toBe("");
});
it("refuses to withdraw into a full party or deposit into a full box", () => {
const world = fixture();
const party = world.state.party;
world.state.party = Array.from({ length: 6 }, (_, i) =>
structuredClone(party[i % 2]),
);
world.state.storage.boxes[0] = [toBoxMon(party[1])];
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
pick(world, "PIDGEY");
const full = pick(world, "WITHDRAW");
expect(full).toContain("The party's full!");
expect(full.at(-1)).toBe("What's up?");
pick(world, "CANCEL");
pick(world, "CANCEL");
world.liveState.storage.boxes[0] = Array.from({ length: 20 }, () =>
toBoxMon(party[1]),
);
pick(world, /^DEPOSIT/);
pick(world, "PIDGEY");
expect(pick(world, "DEPOSIT")).toContain("The BOX is full.");
close(world);
expect(world.state.party).toHaveLength(6);
});
it("releases after confirmation and says goodbye by species", () => {
const world = fixture();
const nicknamed = toBoxMon(world.state.party[1]);
nicknamed.nickname = "BIRDY";
world.state.storage.boxes[0] = [nicknamed];
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
pick(world, "BIRDY");
expect(pick(world, "RELEASE")).toEqual(["Release POKéMON?"]);
world.choose(0);
expect(until(world)).toContain("What's up?");
pick(world, "RELEASE");
world.choose(1);
expect(until(world)).toEqual([
"Released POKéMON.",
"Bye, PIDGEY!",
"BOX1 Choose a POKéMON.",
]);
close(world);
expect(world.state.storage.boxes[0]).toEqual([]);
});
it("switches and names boxes", () => {
const world = fixture();
world.state.storage.boxes[1] = [toBoxMon(world.state.party[1])];
open(world);
pick(world, "BILL's PC");
pick(world, "CHANGE BOX");
expect(world.dialogue?.pages[0]).toBe("CURRENT\nBOX1\nChoose a BOX.");
expect(menuItems(world)).toHaveLength(14);
const choice = world.dialogue?.choice;
expect(choice?.kind === "menu" && choice.details?.slice(0, 2)).toEqual([
"0/20",
"1/20",
]);
pick(world, "BOX2");
expect(menuItems(world)).toEqual(["SWITCH", "NAME", "PRINT", "QUIT"]);
expect(pick(world, "SWITCH")).toEqual([
"When you change a POKéMON BOX, data will be saved. OK?",
]);
world.choose(1);
until(world);
pick(world, "BOX3");
const naming = pick(world, "NAME");
expect(naming).toEqual(["BOX NAME?"]);
world.choose("FAVS");
until(world);
pick(world, "FAVS");
pick(world, "NAME");
// InitString: a blank name keeps the box's name.
world.choose(" ");
until(world);
pick(world, "FAVS");
expect(pick(world, "PRINT")).toContain("There's no POKéMON.");
close(world);
expect(world.state.storage.current).toBe(1);
expect(world.state.storage.names[2]).toBe("FAVS");
open(world);
pick(world, "BILL's PC");
pick(world, /^WITHDRAW/);
expect(world.dialogue?.pages[0]).toBe("BOX2\nChoose a POKéMON.");
expect(menuItems(world)).toEqual(["PIDGEY", "CANCEL"]);
close(world);
});
it("stores, withdraws and tosses items in the player's PC", () => {
const world = fixture();
world.state.bag.keyItems = [MYSTERY_EGG];
open(world);
expect(pick(world, `${world.name}'s PC`)).toEqual([
"Accessed own PC.",
"Item Storage System opened.",
"What do you want to do?",
]);
expect(menuItems(world)).toEqual([
"WITHDRAW ITEM",
"DEPOSIT ITEM",
"TOSS ITEM",
"MAIL BOX",
"LOG OFF",
]);
world.choose(1);
let list = world.dialogue?.choice;
expect(
list?.kind === "martlist" && list.entries.map((e) => e.name),
).toEqual(["POTION", "MYSTERY EGG"]);
world.choose(0);
const quantity = world.dialogue?.choice;
expect(quantity?.kind === "martquantity" && quantity.prompt).toBe(
"How many do you\nwant to deposit?",
);
world.choose(3);
expect(until(world)).toContain("Deposited 3 POTION(S).");
// Key items cannot be tossed, so they move one at a time.
world.choose(1);
expect(until(world)).toContain("Deposited 1 MYSTERY EGG(S).");
world.choose(-1);
until(world);
expect(world.liveState.pcItems).toEqual([
{ item: POTION, quantity: 3 },
{ item: MYSTERY_EGG, quantity: 1 },
]);
expect(world.liveState.bag).toMatchObject({
items: [{ item: POTION, quantity: 2 }],
keyItems: [],
});
pick(world, "TOSS ITEM");
world.choose(1);
expect(until(world)).toContain("That's too impor- tant to toss out!");
world.choose(0);
world.choose(2);
expect(until(world)).toEqual(["Throw away 2 POTION(S)?"]);
world.choose(1);
expect(until(world)).toContain("Discarded POTION(S).");
world.choose(-1);
until(world);
pick(world, "WITHDRAW ITEM");
world.choose(1);
expect(until(world)).toContain("Withdrew 1 MYSTERY EGG(S).");
world.choose(0);
world.choose(1);
expect(until(world)).toContain("Withdrew 1 POTION(S).");
world.choose(-1);
until(world);
expect(pick(world, "MAIL BOX")).toContain("There's no MAIL here.");
close(world);
expect(world.state.pcItems).toEqual([]);
expect(world.state.bag).toMatchObject({
items: [{ item: POTION, quantity: 3 }],
keyItems: [MYSTERY_EGG],
});
});
it("rates the Pokédex on Oak's PC", () => {
const world = fixture();
world.state.flags.add(ENGINE_FLAGS.pokedex);
for (const species of [1, 2, 3, 4, 5])
world.state.pokedex.seen.add(species);
world.state.pokedex.caught.add(1);
open(world);
expect(pick(world, "PROF.OAK's PC")).toEqual([
"PROF.OAK's PC accessed.",
"POKéDEX Rating System opened.",
"Want to get your POKéDEX rated?",
]);
world.choose(1);
const pages = until(world).join(" ");
expect(pages).toContain("Current POKéDEX completion level:");
expect(pages).toContain("5 POKéMON seen 1 POKéMON owned");
expect(pages).toContain("Look for POKéMON in grassy areas!");
expect(pages).toContain("The link to PROF. OAK's PC closed.");
close(world);
});
it("validates and migrates stored Pokémon and items", () => {
const world = fixture();
const before = world.save();
const box = toBoxMon(world.state.party[1]);
for (const [storage, pcItems] of [
[{ ...before.storage, current: 14 }, before.pcItems],
[
{
...before.storage,
boxes: before.storage.boxes.map((_, i) =>
i ? [] : Array.from({ length: 21 }, () => box),
),
},
before.pcItems,
],
[
{
...before.storage,
boxes: before.storage.boxes.map((_, i) =>
i ? [] : [{ ...box, hp: 10 }],
),
},
before.pcItems,
],
[
{ ...before.storage, names: before.storage.names.map(() => " ") },
before.pcItems,
],
[before.storage, [{ item: C.POKE_BALL, quantity: 100 }]],
[
before.storage,
Array.from({ length: 51 }, () => ({ item: POTION, quantity: 1 })),
],
] as const) {
expect(() => world.restore({ ...before, storage, pcItems })).toThrow(
/Invalid native save/,
);
expect(world.save()).toEqual(before);
}
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
delete old.storage;
delete old.pcItems;
world.restore(old);
expect(world.state.storage.boxes.every((box) => !box.length)).toBe(true);
expect(world.state.storage.names.every((name) => name === null)).toBe(true);
expect(world.state.pcItems).toEqual([]);
});
});
describe("PC storage rules", () => {
it("removes from the selected stack, or else the first match", () => {
const list = [
{ item: 1, quantity: 99 },
{ item: 2, quantity: 5 },
{ item: 1, quantity: 3 },
];
expect(removeFromSlot(list, 2, 1, 3)).toBe(true);
expect(list).toEqual([
{ item: 1, quantity: 99 },
{ item: 2, quantity: 5 },
]);
expect(removeFromSlot(list, 1, 1, 9)).toBe(true);
expect(list[0]).toEqual({ item: 1, quantity: 90 });
expect(removeFromSlot(list, 1, 2, 6)).toBe(false);
expect(removeFromSlot(list, 0, 3, 1)).toBe(false);
});
it("holds fifty stacks, filling existing stacks first", () => {
const items = Array.from({ length: MAX_PC_ITEMS }, (_, i) => ({
item: i + 1,
quantity: 98,
}));
expect(receivePCItem(items, 7, 1)).toBe(true);
expect(items[6].quantity).toBe(99);
expect(receivePCItem(items, 7, 1)).toBe(false);
expect(receivePCItem(items, 99, 1)).toBe(false);
});
it("accepts only complete storage", () => {
const storage = emptyStorage();
expect(validStorage(storage)).toBe(true);
expect(validStorage({ ...storage, boxes: storage.boxes.slice(1) })).toBe(
false,
);
expect(validStorage({ ...storage, names: [...storage.names, null] })).toBe(
false,
);
expect(
validStorage({
...storage,
names: ["LONGNAME9", ...storage.names.slice(1)],
}),
).toBe(false);
expect(
validStorage({
...storage,
names: ["GOOD ONE", ...storage.names.slice(1)],
}),
).toBe(true);
expect(validStorage({ ...storage, current: -1 })).toBe(false);
});
});