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
+50
-11
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user