import { PokemonData } from "../rom/pokemon"; import { NAME_LETTERS } from "../rom/charset"; import type { Mail } from "./mail"; /** 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]; /** sPartyMail's entry while the Pokémon holds MAIL. */ mail?: Mail; /** An Egg: wPartySpecies holds EGG for this slot. `species` is what * hatches, `happiness` counts the remaining egg cycles and the nickname * is "EGG". */ egg?: true; } export const PARTY_LENGTH = 6; /** wUnownDex and wFirstUnownSeen, kept with the game. */ export interface UnownRecord { unownDex: number[]; firstUnownSeen: number; } /** UpdateUnownDex: an Unown's letter (GetUnownLetter) is added to the * Unown Dex the first time it joins the party or a box. TryAddMonToParty * also records the first letter seen, when none was. */ export function updateUnownDex( record: UnownRecord, mon: { species: number; dvs: number }, party = true, ) { if (mon.species !== UNOWN_SPECIES) return; const letter = PokemonData.unownLetter(mon.dvs); if (!record.unownDex.includes(letter) && record.unownDex.length < 26) record.unownDex.push(letter); if (party && !record.firstUnownSeen) record.firstUnownSeen = letter; } const UNOWN_SPECIES = 201; const STATUS_NAMES: [number, string][] = [ [0x08, " PSN"], [0x10, " BRN"], [0x20, " FRZ"], [0x40, " PAR"], ]; function statusName(status: number) { if (status & 7) return " SLP"; return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? ""; } /** A party menu row's level, HP and status (or FNT); an Egg shows none. */ export function partyMonDetails(mon: PartyMon): string { if (mon.egg) return ""; return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`; } /** The naming screen's and glyphs, each one character of a name, * written as two small capitals. */ export const PK = "ᴾᴷ", MN = "ᴹᴺ"; /** One character of a name: the naming screen's letters and symbols, plus * the digits and apostrophes of default species names and the European * releases' letters. */ const NAME_CHARACTER = `(?:[A-Za-z0-9 ()":;[\\]?!♂♀/.,×'é${NAME_LETTERS}-]|${PK}|${MN})`; /** Characters a nickname can hold (up to ten). */ export const NICKNAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,10}$`, "u"); /** The same characters, for box names (up to eight). */ export const BOX_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,8}$`, "u"); /** The same characters, for the player's and other trainers' names (up to * seven, PLAYER_NAME_LENGTH less the terminator). */ export const TRAINER_NAME_PATTERN = new RegExp(`^${NAME_CHARACTER}{1,7}$`, "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" && TRAINER_NAME_PATTERN.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) && (mon.egg === undefined || mon.egg === true) ); } 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; } }