Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name boxes), the player's item storage (withdraw, deposit, toss, mail box) and Oak's Pokédex rating. The house PC now opens the full player's PC menu. A catch with a full party goes to the current box through SendMonIntoBox; a full box keeps the ball, and filling it queues Bill's call as a memory script after the battle. Saves gain boxes, box names and PC items, migrated by absence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
451464f7c6
commit
3bdca0099e
21 files changed
+1999
-131
No files matched your search
@@ -0,0 +1,511 @@
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user