Files
Crystal/src/game/party/pokemon.ts
T
Sébastien QUEROLandClaude Opus 5.5 3bdca0099e
Checks / web (push) Canceled after 0s
Port the PC and boxes
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>
2026-09-25 17:12:26 +02:00

252 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}