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

+50 -11
View File
@@ -1,9 +1,13 @@
import type { PokemonData, Pocket } from "../rom/pokemon";
export interface ItemSlot {
item: number;
quantity: number;
}
/** The player's pack, with the original pocket capacities. */
export interface Bag {
items: { item: number; quantity: number }[];
balls: { item: number; quantity: number }[];
items: ItemSlot[];
balls: ItemSlot[];
keyItems: number[];
}
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
@@ -20,8 +24,8 @@ const MAX_ITEMS = 20,
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
* or a new slot is free; stacks then fill to 99 before a new one starts. */
function putInPocket(
pocket: { item: number; quantity: number }[],
export function putInPocket(
pocket: ItemSlot[],
capacity: number,
item: number,
quantity: number,
@@ -80,9 +84,15 @@ export function hasItem(bag: Bag, item: number): boolean {
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
/** A list of item stacks. Without `pocket`, any item with a pack pocket
* (the PC's list holds items from every pocket). */
export function validItemSlots(
data: PokemonData,
list: unknown,
capacity: number,
pocket?: Pocket,
): list is ItemSlot[] {
return (
Array.isArray(list) &&
list.length <= capacity &&
list.every(
@@ -92,13 +102,22 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
Number.isInteger(slot.quantity) &&
slot.quantity >= 1 &&
slot.quantity <= MAX_ITEM_STACK &&
itemIn(data, slot.item, pocket),
);
(pocket
? itemIn(data, slot.item, pocket)
: ["item", "ball", "key"].some((each) =>
itemIn(data, slot.item, each as Pocket),
)),
)
);
}
export function validBag(data: PokemonData, value: unknown): value is Bag {
const bag = value as Bag;
return (
Boolean(bag) &&
typeof bag === "object" &&
slots(bag.items, MAX_ITEMS, "item") &&
slots(bag.balls, MAX_BALLS, "ball") &&
validItemSlots(data, bag.items, MAX_ITEMS, "item") &&
validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
Array.isArray(bag.keyItems) &&
bag.keyItems.length <= MAX_KEY_ITEMS &&
bag.keyItems.every((item) => itemIn(data, item, "key"))
@@ -131,3 +150,23 @@ export function tossItem(bag: Bag, item: number, quantity: number): boolean {
}
return false;
}
/** RemoveItemFromPocket: take from the stack at `index` (wCurItemQuantity)
* if it holds `item`, otherwise from the first stack that does. Fails if
* that stack holds fewer. */
export function removeFromSlot(
list: ItemSlot[],
index: number,
item: number,
quantity: number,
): boolean {
const at =
list[index]?.item === item
? index
: list.findIndex((slot) => slot.item === item);
const slot = list[at];
if (!slot || quantity > slot.quantity) return false;
slot.quantity -= quantity;
if (!slot.quantity) list.splice(at, 1);
return true;
}
+64 -23
View File
@@ -30,9 +30,12 @@ export const PARTY_LENGTH = 6;
/** Characters a nickname can hold: the naming screen's letters and
* symbols, plus the digits and apostrophes of default species names. */
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
/** The same characters, for box names (up to eight). */
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
/** BASE_HAPPINESS */
const BASE_HAPPINESS = 70;
const MAX_STAT_VALUE = 999;
const MAX_LEVEL = 100;
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
function squareRoot(value: number) {
@@ -159,11 +162,30 @@ export function generatePartyMon(
};
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
/** CalcLevel: the highest level whose experience the total reaches. */
export function levelForExp(
data: PokemonData,
value: unknown,
): value is PartyMon {
species: number,
exp: number,
): number {
const growth = data.baseData(species).growthRate;
let level = 1;
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
level++;
return level;
}
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
* packed byte at 64. The PP Ups stay in the top two bits. */
export function maxPP(data: PokemonData, move: number, ups: number): number {
const base = data.move(move).pp;
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
}
/** The box_struct fields of a stored Pokémon: everything but status, HP
* and stats. */
export function validBoxFields(value: unknown): boolean {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
@@ -171,28 +193,47 @@ export function validPartyMon(
Array.isArray(value) &&
value.length === length &&
value.every((item) => integer(item, 0, max));
try {
return (
Boolean(mon) &&
typeof mon === "object" &&
integer(mon.species, 1, 251) &&
integer(mon.item, 0, 255) &&
tuple(mon.moves, 4, 251) &&
integer(mon.otId, 0, 0xffff) &&
typeof mon.ot === "string" &&
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
typeof mon.nickname === "string" &&
NICKNAME_PATTERN.test(mon.nickname) &&
integer(mon.exp, 0, 0xffffff) &&
tuple(mon.statExp, 5, 0xffff) &&
integer(mon.dvs, 0, 0xffff) &&
tuple(mon.pp, 4, 255) &&
integer(mon.happiness, 0, 255) &&
integer(mon.pokerus, 0, 255) &&
integer(mon.caughtData, 0, 0xffff) &&
integer(mon.level, 1, 100)
);
} catch {
return false;
}
}
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
export function validPartyMon(
data: PokemonData,
value: unknown,
): value is PartyMon {
const mon = value as PartyMon;
const integer = (value: unknown, min: number, max: number): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
try {
if (
!mon ||
typeof mon !== "object" ||
!integer(mon.species, 1, 251) ||
!integer(mon.item, 0, 255) ||
!tuple(mon.moves, 4, 251) ||
!integer(mon.otId, 0, 0xffff) ||
typeof mon.ot !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
typeof mon.nickname !== "string" ||
!NICKNAME_PATTERN.test(mon.nickname) ||
!integer(mon.exp, 0, 0xffffff) ||
!tuple(mon.statExp, 5, 0xffff) ||
!integer(mon.dvs, 0, 0xffff) ||
!tuple(mon.pp, 4, 255) ||
!integer(mon.happiness, 0, 255) ||
!integer(mon.pokerus, 0, 255) ||
!integer(mon.caughtData, 0, 0xffff) ||
!integer(mon.level, 1, 100) ||
!validBoxFields(mon) ||
!integer(mon.status, 0, 255) ||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
!Array.isArray(mon.stats) ||
mon.stats.length !== 6 ||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
!integer(mon.hp, 0, mon.stats[0])
)
return false;
+133
View File
@@ -0,0 +1,133 @@
import type { PokemonData } from "../rom/pokemon";
import {
BOX_NAME_PATTERN,
calcStats,
levelForExp,
maxPP,
validBoxFields,
type PartyMon,
} from "./pokemon";
import { putInPocket, validItemSlots, type ItemSlot } from "./bag";
/** NUM_BOXES and MONS_PER_BOX (constants/pokemon_data_constants.asm). */
export const NUM_BOXES = 14;
export const MONS_PER_BOX = 20;
/** BOX_NAME_LENGTH minus the terminator. */
export const BOX_NAME_MAX = 8;
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
export const MAX_PC_ITEMS = 50;
/** The fields a stored BoxMon has, so no party fields slip into a box. */
const BOX_MON_FIELDS = 14;
/** box_struct: a party_struct without status, HP and stats. */
export type BoxMon = Omit<PartyMon, "status" | "hp" | "stats">;
/** Bill's PC: every box, wCurBox and wBoxNames. A null name is the one
* SetDefaultBoxNames writes ("BOX" and the box number, from the ROM). */
export interface PokemonStorage {
current: number;
names: (string | null)[];
boxes: BoxMon[][];
}
export const emptyStorage = (): PokemonStorage => ({
current: 0,
names: Array.from({ length: NUM_BOXES }, () => null),
boxes: Array.from({ length: NUM_BOXES }, () => []),
});
export const copyStorage = (storage: PokemonStorage): PokemonStorage => ({
current: storage.current,
names: [...storage.names],
boxes: structuredClone(storage.boxes),
});
export const currentBox = (storage: PokemonStorage) =>
storage.boxes[storage.current];
/** The first BOXMON_STRUCT_LENGTH bytes of a party struct. */
export function toBoxMon(mon: PartyMon): BoxMon {
const {
status: _status,
hp: _hp,
stats: _stats,
...box
} = structuredClone(mon);
return box;
}
/** RestorePPOfDepositedPokemon: each move's PP becomes its maximum,
* keeping the PP Ups (GetMaxPPOfMove through ComputeMaxPP). */
export function restorePP(data: PokemonData, mon: BoxMon) {
mon.pp = mon.moves.map((move, slot) =>
move ? maxPP(data, move, mon.pp[slot] >> 6) : mon.pp[slot],
) as BoxMon["pp"];
}
/** SendGetMonIntoFromBox with PC_DEPOSIT: the Pokémon joins the end of
* the current box with its PP restored. Returns false if the box is full. */
export function depositMon(
data: PokemonData,
storage: PokemonStorage,
mon: PartyMon,
): boolean {
const box = currentBox(storage);
if (box.length >= MONS_PER_BOX) return false;
const stored = toBoxMon(mon);
restorePP(data, stored);
box.push(stored);
return true;
}
/** SendGetMonIntoFromBox with PC_WITHDRAW: CalcLevel from the experience,
* then CalcMonStats with stat experience, no status and full HP. */
export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
const mon = structuredClone(box);
const level = levelForExp(data, mon.species, mon.exp);
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
return { ...mon, level, status: 0, hp: stats[0], stats };
}
/** ReceiveItem into wNumPCItems. */
export function receivePCItem(
items: ItemSlot[],
item: number,
quantity: number,
): boolean {
return putInPocket(items, MAX_PC_ITEMS, item, quantity);
}
export function validStorage(value: unknown): value is PokemonStorage {
const storage = value as PokemonStorage;
return (
Boolean(storage) &&
typeof storage === "object" &&
Number.isInteger(storage.current) &&
storage.current >= 0 &&
storage.current < NUM_BOXES &&
Array.isArray(storage.names) &&
storage.names.length === NUM_BOXES &&
storage.names.every(
(name) =>
name === null ||
(typeof name === "string" &&
BOX_NAME_PATTERN.test(name) &&
!/^ *$/.test(name)),
) &&
Array.isArray(storage.boxes) &&
storage.boxes.length === NUM_BOXES &&
storage.boxes.every(
(box) =>
Array.isArray(box) &&
box.length <= MONS_PER_BOX &&
box.every(
(mon) =>
validBoxFields(mon) &&
Object.keys(mon as object).length === BOX_MON_FIELDS,
),
)
);
}
export function validPCItems(
data: PokemonData,
value: unknown,
): value is ItemSlot[] {
return validItemSlots(data, value, MAX_PC_ITEMS);
}