refactor: Phase 0 — 清除重复,委托 @pkmn 生态

- 删除硬编码 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 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 00:05:05 +08:00
parent 336159ee18
commit 96f3e1b309
8 changed files with 358 additions and 18 deletions

View File

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

View File

@@ -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,

View File

@@ -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
})()

View File

@@ -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,
}
}

View File

@@ -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<string, StatName> = {
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<StatName, string> = {
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<StatName, number> {
const result = {} as Record<StatName, number>
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, string>): string {
return abilities['0']?.toLowerCase() ?? ''
}

View File

@@ -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<SpeciesId, {
growthRate: GrowthRate
captureRate: number
baseHappiness: number
flavorText: string
}> = {
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<SpeciesId, SpeciesData>()
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<SpeciesId, SpeciesData> {
const result = {} as Record<SpeciesId, SpeciesData>
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<SpeciesId, SpeciesData> = new Proxy({} as Record<SpeciesId, SpeciesData>, {
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<void> {
return Promise.resolve()
}
/** No-op — data is now built-in from @pkmn/sim */
export async function refreshAllSpeciesData(): Promise<void> {
// Clear cache to force rebuild
speciesCache.clear()
}
// ─── Dex number mapping ───
export const DEX_TO_SPECIES: Record<number, SpeciesId> = {
1: 'bulbasaur',
2: 'ivysaur',
3: 'venusaur',
4: 'charmander',
5: 'charmeleon',
6: 'charizard',
7: 'squirtle',
8: 'wartortle',
9: 'blastoise',
25: 'pikachu',
}

View File

@@ -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'

View File

@@ -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'