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; } }