From 96f3e1b3099da8af9b112929546191565369217b Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 22 Apr 2026 00:05:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Phase=200=20=E2=80=94=20=E6=B8=85?= =?UTF-8?q?=E9=99=A4=E9=87=8D=E5=A4=8D=EF=BC=8C=E5=A7=94=E6=89=98=20@pkmn?= =?UTF-8?q?=20=E7=94=9F=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除硬编码 NATURES 常量,nature.ts 委托 Dex.natures - 删除硬编码 EVOLUTION_CHAINS,evolution.ts 委托 Dex.species.evos - calculateStats() 手写公式替换为 gen.stats.calc() - 统一 TO_DEX_STAT/FROM_DEX_STAT 映射到 pkmn.ts - 简化 species.ts buildEvolutionChain() 复用 getNextEvolution() - 添加 NatureName/NatureEffect 类型定义 Co-Authored-By: Claude Opus 4.6 --- packages/pokemon/src/core/creature.ts | 37 ++--- packages/pokemon/src/core/evolution.ts | 2 +- packages/pokemon/src/data/evolution.ts | 45 ++++++ packages/pokemon/src/data/nature.ts | 32 +++++ packages/pokemon/src/data/pkmn.ts | 59 ++++++++ packages/pokemon/src/data/species.ts | 191 +++++++++++++++++++++++++ packages/pokemon/src/index.ts | 5 + packages/pokemon/src/types.ts | 5 + 8 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 packages/pokemon/src/data/evolution.ts create mode 100644 packages/pokemon/src/data/nature.ts create mode 100644 packages/pokemon/src/data/pkmn.ts create mode 100644 packages/pokemon/src/data/species.ts diff --git a/packages/pokemon/src/core/creature.ts b/packages/pokemon/src/core/creature.ts index 8fec40c50..7c5bfc754 100644 --- a/packages/pokemon/src/core/creature.ts +++ b/packages/pokemon/src/core/creature.ts @@ -4,6 +4,7 @@ import { STAT_NAMES } from '../types' import { getSpeciesData } from '../data/species' import { determineGender } from './gender' import { levelFromXp } from '../data/xpTable' +import { gen, TO_DEX_STAT } from '../data/pkmn' /** * Generate a new creature of the given species. @@ -37,28 +38,30 @@ export function generateCreature(speciesId: SpeciesId, seed?: number): Creature } /** - * Calculate actual stats for a creature using Pokémon stat formulas. - * HP: floor((2 * base + iv + floor(ev/4)) * level / 100) + level + 10 - * Other: floor((2 * base + iv + floor(ev/4)) * level / 100) + 5 + * Calculate actual stats for a creature using @pkmn/data stats.calc(). + * Handles base stats, IV, EV, level, and nature correction internally. */ export function calculateStats(creature: Creature): StatsResult { - const species = getSpeciesData(creature.speciesId) - const level = creature.level - const result: StatsResult = {} as StatsResult + const species = gen.species.get(creature.speciesId) + if (!species) throw new Error(`Species ${creature.speciesId} not found`) + // Get nature if creature has one (Phase 1 adds nature field) + const nature = 'nature' in creature && creature.nature + ? gen.natures.get(creature.nature as string) + : undefined + + const result = {} as StatsResult for (const stat of STAT_NAMES) { - const base = species.baseStats[stat] - const iv = creature.iv[stat] - const ev = creature.ev[stat] - const raw = Math.floor((2 * base + iv + Math.floor(ev / 4)) * level / 100) - - if (stat === 'hp') { - result[stat] = raw + level + 10 - } else { - result[stat] = raw + 5 - } + const dexKey = TO_DEX_STAT[stat] as 'hp' | 'atk' | 'def' | 'spa' | 'spd' | 'spe' + result[stat] = gen.stats.calc( + dexKey, + species.baseStats[dexKey], + creature.iv[stat], + creature.ev[stat], + creature.level, + nature ?? undefined, + ) } - return result } diff --git a/packages/pokemon/src/core/evolution.ts b/packages/pokemon/src/core/evolution.ts index 479e45f26..5b8727f27 100644 --- a/packages/pokemon/src/core/evolution.ts +++ b/packages/pokemon/src/core/evolution.ts @@ -13,7 +13,7 @@ export function checkEvolution(creature: Creature): EvolutionResult | null { if (!nextEvo) return null // Check level-up conditions - if (nextEvo.trigger === 'level_up' && creature.level >= nextEvo.minLevel) { + if (nextEvo.trigger === 'level_up' && nextEvo.minLevel != null && creature.level >= nextEvo.minLevel) { return { from: creature.speciesId, to: nextEvo.to, diff --git a/packages/pokemon/src/data/evolution.ts b/packages/pokemon/src/data/evolution.ts new file mode 100644 index 000000000..6da728a61 --- /dev/null +++ b/packages/pokemon/src/data/evolution.ts @@ -0,0 +1,45 @@ +import { Dex } from '@pkmn/sim' +import type { SpeciesId } from '../types' +import { ALL_SPECIES_IDS } from '../types' + +export interface EvolutionChainStep { + from: SpeciesId + to: SpeciesId + trigger: 'level_up' | 'item' | 'trade' | 'friendship' + minLevel?: number +} + +/** Find the next evolution for a species, dynamically from Dex */ +export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | undefined { + const dex = Dex.species.get(speciesId) + if (!dex?.evos?.length) return undefined + + // Take the first evolution target (most species have single evo path) + const target = dex.evos[0]!.toLowerCase() + if (!ALL_SPECIES_IDS.includes(target as SpeciesId)) return undefined + + const targetDex = Dex.species.get(target) + if (!targetDex?.exists) return undefined + + const trigger = dex.evoType === 'trade' ? 'trade' + : dex.evoType === 'useItem' ? 'item' + : dex.evoType === 'levelFriendship' ? 'friendship' + : 'level_up' + + return { + from: speciesId, + to: target as SpeciesId, + trigger, + minLevel: targetDex.evoLevel ?? undefined, + } +} + +/** @deprecated Use getNextEvolution() instead. Kept for backward compat. */ +export const EVOLUTION_CHAINS: EvolutionChainStep[] = (() => { + const chains: EvolutionChainStep[] = [] + for (const id of ALL_SPECIES_IDS) { + const evo = getNextEvolution(id) + if (evo) chains.push(evo) + } + return chains +})() diff --git a/packages/pokemon/src/data/nature.ts b/packages/pokemon/src/data/nature.ts new file mode 100644 index 000000000..7bc408c91 --- /dev/null +++ b/packages/pokemon/src/data/nature.ts @@ -0,0 +1,32 @@ +import { Dex } from '@pkmn/sim' +import type { NatureName, NatureEffect, NatureStat } from '../types' + +// All 25 canonical nature names (Dex.natures is not iterable, so we list them) +const NATURE_IDS: NatureName[] = [ + 'hardy', 'lonely', 'brave', 'adamant', 'naughty', + 'bold', 'docile', 'relaxed', 'impish', 'lax', + 'timid', 'hasty', 'serious', 'jolly', 'naive', + 'modest', 'mild', 'quiet', 'bashful', 'rash', + 'calm', 'gentle', 'sassy', 'careful', 'quirky', +] + +/** Get all nature names */ +export function getAllNatureNames(): NatureName[] { + return NATURE_IDS.filter(name => Dex.natures.get(name)?.exists) +} + +/** Randomly assign a nature */ +export function randomNature(): NatureName { + const names = getAllNatureNames() + return names[Math.floor(Math.random() * names.length)]! +} + +/** Get nature effect (plus/minus stat, or null for neutral) — delegates to Dex.natures */ +export function getNatureEffect(nature: NatureName): NatureEffect { + const n = Dex.natures.get(nature) + if (!n?.exists) return { plus: null, minus: null } + return { + plus: (n.plus as NatureStat | undefined) ?? null, + minus: (n.minus as NatureStat | undefined) ?? null, + } +} diff --git a/packages/pokemon/src/data/pkmn.ts b/packages/pokemon/src/data/pkmn.ts new file mode 100644 index 000000000..1d2cec0cc --- /dev/null +++ b/packages/pokemon/src/data/pkmn.ts @@ -0,0 +1,59 @@ +import { Dex } from '@pkmn/sim' +import { Generations } from '@pkmn/data' +import type { StatName } from '../types' + +// Singleton Gen 9 data source +const gens = new Generations(Dex as unknown as import('@pkmn/data').Dex) +export const gen = gens.get(9) + +// Stat name mapping: @pkmn/sim → our StatName +export const FROM_DEX_STAT: Record = { + hp: 'hp', atk: 'attack', def: 'defense', + spa: 'spAtk', spd: 'spDef', spe: 'speed', +} + +// Stat name mapping: our StatName → @pkmn/sim abbreviation +export const TO_DEX_STAT: Record = { + hp: 'hp', attack: 'atk', defense: 'def', + spAtk: 'spa', spDef: 'spd', speed: 'spe', +} + +/** Query species from Dex */ +export function getSpecies(id: string) { + return gen.species.get(id) +} + +/** Query move from Dex */ +export function getMove(id: string) { + return gen.moves.get(id) +} + +/** Query ability from Dex */ +export function getAbility(id: string) { + return gen.abilities.get(id) +} + +/** Query type from Dex */ +export function getType(id: string) { + return gen.types.get(id) +} + +/** Map Dex baseStats to our StatName format */ +export function mapBaseStats(dexStats: { hp: number; atk: number; def: number; spa: number; spd: number; spe: number }): Record { + const result = {} as Record + for (const [dexKey, ourKey] of Object.entries(FROM_DEX_STAT)) { + result[ourKey] = dexStats[dexKey as keyof typeof dexStats] ?? 0 + } + return result +} + +/** Get gender rate from Dex genderRatio (M/F ratio → our genderRate 0-8) */ +export function mapGenderRatio(genderRatio?: { M: number; F: number } | string): number { + if (!genderRatio || typeof genderRatio === 'string') return -1 // genderless + return Math.round(genderRatio.F * 8) +} + +/** Get primary ability ID from Dex abilities object */ +export function getPrimaryAbility(abilities: Record): string { + return abilities['0']?.toLowerCase() ?? '' +} diff --git a/packages/pokemon/src/data/species.ts b/packages/pokemon/src/data/species.ts new file mode 100644 index 000000000..dfa3a7c54 --- /dev/null +++ b/packages/pokemon/src/data/species.ts @@ -0,0 +1,191 @@ +import type { SpeciesData, SpeciesId, GrowthRate } from '../types' +import { ALL_SPECIES_IDS } from '../types' +import { getSpecies, mapBaseStats, mapGenderRatio } from './pkmn' +import { getNextEvolution } from './evolution' +import { SPECIES_I18N, SPECIES_PERSONALITY } from './names' + +// ─── Supplementary data (fields not provided by @pkmn/sim) ─── + +const SUPPLEMENT: Record = { + bulbasaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'A strange seed was planted on its back at birth. The plant sprouts and grows with this Pokémon.', + }, + ivysaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'When the bulb on its back grows large, it appears to lose the ability to stand on its hind legs.', + }, + venusaur: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'The plant blooms when it is absorbing solar energy. It stays on the move to seek sunlight.', + }, + charmander: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Obviously prefers hot places. When it rains, steam is said to spout from the tip of its tail.', + }, + charmeleon: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Tough fights could excite this Pokémon. When excited, it may blow out bluish-white flames.', + }, + charizard: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Spits fire that is hot enough to melt boulders. Known to cause forest fires unintentionally.', + }, + squirtle: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'After birth, its back swells and hardens into a shell. Powerfully sprays foam from its mouth.', + }, + wartortle: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'Often hides in water to stalk unwary prey. For swimming fast, it moves its ears to maintain balance.', + }, + blastoise: { + growthRate: 'medium-slow', + captureRate: 45, + baseHappiness: 70, + flavorText: 'It crushes its foe under its heavy body to cause fainting. In a pinch, it will withdraw inside its shell.', + }, + pikachu: { + growthRate: 'medium-fast', + captureRate: 190, + baseHappiness: 70, + flavorText: 'When several of these Pokémon gather, their electricity can build and cause lightning storms.', + }, +} + +// ─── Evolution chain builder (from Dex evos field) ─── + +function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'] { + const evo = getNextEvolution(speciesId) + if (!evo) return undefined + return [{ trigger: evo.trigger, level: evo.minLevel, into: evo.to }] +} + +// ─── Build SpeciesData from Dex + supplement ─── + +function buildSpeciesData(id: SpeciesId): SpeciesData { + const dex = getSpecies(id) + const sup = SUPPLEMENT[id] + const i18n = SPECIES_I18N[id] + const personality = SPECIES_PERSONALITY[id] + + if (!dex) { + // Fallback if Dex somehow doesn't have the species (shouldn't happen for MVP) + throw new Error(`Species ${id} not found in @pkmn/sim Dex`) + } + + return { + id, + name: dex.name, + names: i18n ?? { en: dex.name }, + dexNumber: dex.num, + genderRate: mapGenderRatio(dex.genderRatio as { M: number; F: number } | undefined), + baseStats: mapBaseStats(dex.baseStats), + types: dex.types.map((t: string) => t.toLowerCase()) as [string, string?], + baseHappiness: sup.baseHappiness, + growthRate: sup.growthRate, + captureRate: sup.captureRate, + personality: personality ?? '', + evolutionChain: buildEvolutionChain(id), + shinyChance: 1 / 4096, + flavorText: sup.flavorText, + } +} + +// ─── In-memory cache (built once, immutable) ─── + +const speciesCache = new Map() + +function getCached(id: SpeciesId): SpeciesData { + let data = speciesCache.get(id) + if (!data) { + data = buildSpeciesData(id) + speciesCache.set(id, data) + } + return data +} + +// ─── Sync getters (used by all consumers) ─── + +/** Get species data by ID. */ +export function getSpeciesData(id: SpeciesId): SpeciesData { + return getCached(id) +} + +/** Get all species data as a Record. */ +export function getAllSpeciesData(): Record { + const result = {} as Record + for (const id of ALL_SPECIES_IDS) { + result[id] = getCached(id) + } + return result +} + +/** + * Synchronous getter that returns the full map. + * @deprecated Use getSpeciesData / getAllSpeciesData + */ +export const SPECIES_DATA: Record = new Proxy({} as Record, { + get(_, prop: string) { + return getSpeciesData(prop as SpeciesId) + }, + ownKeys() { + return ALL_SPECIES_IDS as unknown as string[] + }, + has(_, prop) { + return ALL_SPECIES_IDS.includes(prop as SpeciesId) + }, + getOwnPropertyDescriptor(_, prop) { + if (ALL_SPECIES_IDS.includes(prop as SpeciesId)) { + return { configurable: true, enumerable: true, value: getSpeciesData(prop as SpeciesId) } + } + return undefined + }, +}) + +/** No-op — data is now built-in from @pkmn/sim */ +export function ensureSpeciesData(): Promise { + return Promise.resolve() +} + +/** No-op — data is now built-in from @pkmn/sim */ +export async function refreshAllSpeciesData(): Promise { + // Clear cache to force rebuild + speciesCache.clear() +} + +// ─── Dex number mapping ─── + +export const DEX_TO_SPECIES: Record = { + 1: 'bulbasaur', + 2: 'ivysaur', + 3: 'venusaur', + 4: 'charmander', + 5: 'charmeleon', + 6: 'charizard', + 7: 'squirtle', + 8: 'wartortle', + 9: 'blastoise', + 25: 'pikachu', +} diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts index 752894ffe..13f982afa 100644 --- a/packages/pokemon/src/index.ts +++ b/packages/pokemon/src/index.ts @@ -1,6 +1,9 @@ // Types export type { StatName, + NatureName, + NatureStat, + NatureEffect, SpeciesId, Gender, EvolutionTrigger, @@ -23,7 +26,9 @@ export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensure export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './data/evMapping' export { xpForLevel, levelFromXp, xpToNextLevel } from './data/xpTable' export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './data/names' +export { getAllNatureNames, randomNature, getNatureEffect } from './data/nature' export { getNextEvolution, EVOLUTION_CHAINS } from './data/evolution' +export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn' // Core export { generateCreature, calculateStats, getCreatureName, recalculateLevel, getActiveCreature, getTotalEV } from './core/creature' diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts index de00df06f..9eb5a73c9 100644 --- a/packages/pokemon/src/types.ts +++ b/packages/pokemon/src/types.ts @@ -36,6 +36,11 @@ export const ALL_SPECIES_IDS: SpeciesId[] = [ 'pikachu', ] +// Nature (delegated to @pkmn/sim Dex.natures) +export type NatureName = string +export type NatureStat = 'attack' | 'defense' | 'spAtk' | 'spDef' | 'speed' +export type NatureEffect = { plus: NatureStat | null; minus: NatureStat | null } + // Gender export type Gender = 'male' | 'female' | 'genderless'