Port the PC and boxes
Checks / web (push) Canceled after 0s

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:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-25 17:12:26 +02:00
1 parent 451464f7c6
commit 3bdca0099e
21 files changed
+1999 -131

No files matched your search

+93 -8
View File
@@ -163,23 +163,108 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
});
it("rolls back the whole battle when a full party would need the PC", () => {
/** Throw balls until the battle ends, answering prompts; returns the
* battle messages and the pages shown afterwards. */
const catchAll = (world: GameWorld, nickname?: string) => {
const messages: string[] = [], pages: string[] = [];
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) {
const page = world.dialogue.pages[world.dialogue.index];
if (pages.at(-1) !== page) pages.push(page);
world.advanceDialogue();
} else world.tick();
continue;
}
const text = view.message?.text;
if (text && messages.at(-1) !== text) messages.push(text);
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(nickname ? 1 : 0);
else if (prompt?.kind === "name") world.battleName(nickname!);
else if (view.message?.prompt) world.interact();
else world.tick();
}
return {
messages: messages.map((text) => text.replace(/\n/g, " ")),
pages: pages.map((text) => text.replace(/\n/g, " ")),
};
};
const fullParty = (boxed = 0) => {
const world = battleFixtureWorld(assets());
const mon = world.state.party[0];
world.state.party = Array.from({ length: 6 }, () => structuredClone(mon));
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
const { status: _s, hp: _h, stats: _t, ...box } = mon;
world.state.storage.boxes[0] = Array.from({ length: boxed }, () => structuredClone(box));
return world;
};
it("sends a catch with a full party to the current box", () => {
const world = fullParty();
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
const { messages } = catchAll(world, "PIDGE");
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("PIDGE was sent to BILL's PC.");
expect(world.state.party).toHaveLength(6);
// SendMonIntoBox: first in the box, with full PP and base happiness.
const [boxed] = world.state.storage.boxes[0];
expect(boxed).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name, happiness: 70, statExp: [0, 0, 0, 0, 0] });
expect(boxed).not.toHaveProperty("hp");
const data = world.assets.pokemon;
expect(boxed.pp).toEqual(boxed.moves.map((move) => (move ? data.move(move).pp : 0)));
expect(boxed.caughtData >> 8).toBe((2 << 6) | 3);
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("keeps the ball when the party and the box are full", () => {
const world = fullParty(20);
const before = world.save();
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++)
if (world.battle?.message?.prompt) world.interact();
else world.tick();
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
for (let tick = 0; tick < 200 && world.battle; tick++) {
if (world.battle.prompt?.kind === "action")
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
const messages: string[] = [];
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) {
const text = world.battle?.message?.text;
if (text && messages.at(-1) !== text) messages.push(text.replace(/\n/g, " "));
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
// Ball_BoxIsFullMessage: the item is not used and the turn is not taken.
expect(messages).toEqual(["The POKéMON BOX is full. That can't be used now."]);
expect(world.battle?.prompt?.kind).toBe("action");
expect(world.battle?.engine.enemy.mon.hp).toBe(world.battle?.engine.enemy.mon.maxHp);
for (let tick = 0; tick < 2000 && world.battle; tick++) {
if (world.battle.prompt?.kind === "action") world.battleAction({ action: "run" });
else if (world.battle.message?.prompt) world.interact();
else world.tick();
}
expect(world.battle).toBeUndefined();
expect(world.notice).toMatch(/not been ported/);
expect(world.state.bag).toEqual({ ...before.bag });
expect(world.state.party).toHaveLength(6);
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bag).toEqual(before.bag);
expect(world.state.storage).toEqual(before.storage);
});
it("has Bill call after a catch fills the box", () => {
const world = fullParty(19);
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
const { messages } = catchAll(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("PIDGEY was sent to BILL's PC.");
expect(world.state.storage.boxes[0]).toHaveLength(20);
// Script_SpecialBillCall runs at the next PlayerEvents.
expect(world.busy).toBe(false);
world.tick();
const call = settle(world).join(" ").replace(/\n/g, " ");
expect(call).toContain("It's me, BILL!");
expect(call).toContain("Click!");
expect(world.busy).toBe(false);
});
});