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>
252 lines
8.0 KiB
TypeScript
252 lines
8.0 KiB
TypeScript
import type { PokemonData } from "../rom/pokemon";
|
||
|
||
/** A party Pokémon with the fields of the original party_struct. */
|
||
export interface PartyMon {
|
||
species: number;
|
||
item: number;
|
||
/** Move IDs; 0 marks an empty slot. */
|
||
moves: [number, number, number, number];
|
||
otId: number;
|
||
ot: string;
|
||
nickname: string;
|
||
exp: number;
|
||
/** HP, Attack, Defense, Speed, Special stat experience. */
|
||
statExp: [number, number, number, number, number];
|
||
/** Attack, Defense, Speed, Special DVs as four nibbles. */
|
||
dvs: number;
|
||
pp: [number, number, number, number];
|
||
happiness: number;
|
||
pokerus: number;
|
||
/** Time of day and level, then gender and landmark (SetCaughtData). */
|
||
caughtData: number;
|
||
level: number;
|
||
status: number;
|
||
hp: number;
|
||
/** Max HP, Attack, Defense, Speed, Sp. Atk, Sp. Def. */
|
||
stats: [number, number, number, number, number, number];
|
||
}
|
||
|
||
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) {
|
||
for (let root = 1; root < 255; root++) if (root * root >= value) return root;
|
||
return 255;
|
||
}
|
||
|
||
/** DVs per stat; the HP DV is built from the low bits of the others. */
|
||
export function statDv(dvs: number, stat: number): number {
|
||
const attack = (dvs >> 12) & 15,
|
||
defense = (dvs >> 8) & 15,
|
||
speed = (dvs >> 4) & 15,
|
||
special = dvs & 15;
|
||
if (stat === 0)
|
||
return (
|
||
((attack & 1) << 3) |
|
||
((defense & 1) << 2) |
|
||
((speed & 1) << 1) |
|
||
(special & 1)
|
||
);
|
||
return [attack, defense, speed, special, special][stat - 1];
|
||
}
|
||
|
||
/** CalcMonStatC for stat 0-5 (HP, Atk, Def, Spd, SpAtk, SpDef). */
|
||
export function calcStat(
|
||
base: number,
|
||
dvs: number,
|
||
stat: number,
|
||
level: number,
|
||
statExp: readonly number[] | undefined,
|
||
): number {
|
||
// Special attack and defense share the Special stat experience.
|
||
const experience = statExp ? statExp[Math.min(stat, 4)] : 0;
|
||
const bonus = statExp ? Math.floor(squareRoot(experience) / 4) : 0;
|
||
const value = Math.floor(
|
||
(((base + statDv(dvs, stat)) * 2 + bonus) * level) / 100,
|
||
);
|
||
return Math.min(MAX_STAT_VALUE, value + (stat === 0 ? level + 10 : 5));
|
||
}
|
||
|
||
export function calcStats(
|
||
data: PokemonData,
|
||
species: number,
|
||
dvs: number,
|
||
level: number,
|
||
statExp?: readonly number[],
|
||
): PartyMon["stats"] {
|
||
const base = data.baseData(species).stats;
|
||
return base.map((value, stat) =>
|
||
calcStat(value, dvs, stat, level, statExp),
|
||
) as PartyMon["stats"];
|
||
}
|
||
|
||
/** FillMoves for a new Pokémon: learn every move up to its level, skipping
|
||
* repeats and shifting out the oldest move when all slots are full. */
|
||
export function initialMoves(
|
||
data: PokemonData,
|
||
species: number,
|
||
level: number,
|
||
): PartyMon["moves"] {
|
||
const moves: number[] = [];
|
||
for (const learned of data.learnset(species)) {
|
||
if (learned.level > level) break;
|
||
if (moves.includes(learned.move)) continue;
|
||
if (moves.length === 4) moves.shift();
|
||
moves.push(learned.move);
|
||
}
|
||
while (moves.length < 4) moves.push(0);
|
||
return moves as PartyMon["moves"];
|
||
}
|
||
|
||
/** SetCaughtData (SetBoxmonOrEggmonCaughtData): time of day and level,
|
||
* then the player's gender bit and the map's landmark. */
|
||
export function caughtData(
|
||
timeOfDay: number,
|
||
level: number,
|
||
gender: number,
|
||
landmark: number,
|
||
): number {
|
||
return (
|
||
(((((timeOfDay + 1) & 3) << 6) | level) & 0xff) << 8 |
|
||
(gender << 7) |
|
||
landmark
|
||
);
|
||
}
|
||
|
||
/** GeneratePartyMonStats for a Pokémon given by a script (not a wild or
|
||
* trainer battle): random DVs, level-up moves, full PP and HP. */
|
||
export function generatePartyMon(
|
||
data: PokemonData,
|
||
options: {
|
||
species: number;
|
||
level: number;
|
||
item: number;
|
||
otId: number;
|
||
ot: string;
|
||
dvs: number;
|
||
caughtData: number;
|
||
},
|
||
): PartyMon {
|
||
const { species, level } = options;
|
||
if (!Number.isInteger(level) || level < 1 || level > 100)
|
||
throw new Error("Invalid level.");
|
||
const moves = initialMoves(data, species, level);
|
||
const stats = calcStats(data, species, options.dvs, level);
|
||
return {
|
||
species,
|
||
item: options.item,
|
||
moves,
|
||
otId: options.otId,
|
||
ot: options.ot,
|
||
nickname: data.speciesName(species),
|
||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||
statExp: [0, 0, 0, 0, 0],
|
||
dvs: options.dvs,
|
||
pp: moves.map((move) => (move ? data.move(move).pp : 0)) as PartyMon["pp"],
|
||
happiness: BASE_HAPPINESS,
|
||
pokerus: 0,
|
||
caughtData: options.caughtData,
|
||
level,
|
||
status: 0,
|
||
hp: stats[0],
|
||
stats,
|
||
};
|
||
}
|
||
|
||
/** CalcLevel: the highest level whose experience the total reaches. */
|
||
export function levelForExp(
|
||
data: PokemonData,
|
||
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;
|
||
const tuple = (value: unknown, length: number, max: number) =>
|
||
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 (
|
||
!validBoxFields(mon) ||
|
||
!integer(mon.status, 0, 255) ||
|
||
!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;
|
||
// GiveExperiencePoints awards stat experience after every victory, but
|
||
// CalcMonStats only runs on a level-up (or a later PC recalculation).
|
||
// Preserve legitimate pending gains instead of silently applying them on
|
||
// load. Stored stats must stay inside the ROM-derived possible range.
|
||
const minimum = calcStats(data, mon.species, mon.dvs, mon.level);
|
||
const maximum = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||
return mon.stats.every((stat, index) =>
|
||
stat >= minimum[index] && stat <= maximum[index]);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|