Files
Crystal/tests/pc.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 b13e09ec20 Port the remaining battle items, the stats screen and hBattleTurn at the menu
- Run every non-ball item effect usable in battle from the ROM's
  ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers
  and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll.
- Read happiness changes from HappinessChanges, including the larger
  level-up gain where a Pokémon was caught.
- Model hBattleTurn at the battle menu, including the trainer AI's writes,
  and drop the status-heal refusal.
- Port the stats screen for battles and Bill's PC.
- Compare the send-out and withdraw texts, RestoreHealth, RestorePP,
  ChangeHappiness and CalcPlayerStats with the original routines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:24:55 +02:00

552 lines
18 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("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);
});
});