feat: 同步 pkmn Dex 全部 1025 只精灵,新增 SpeciesPicker 搜索选择器

- SpeciesId 从 10 项联合类型改为 string,动态从 @pkmn/sim Dex 加载 1025 只精灵
- getSpecies() 改用 Dex.species.get() 直接查找(gen wrapper 仅覆盖 733/1025)
- SUPPLEMENT/DEX_TO_SPECIES 动态生成,未收录 species 使用默认值兜底
- names/fallback 改为 partial records,缺失时回退到 Dex 英文名/通用 sprite
- 新增 SpeciesPicker 组件(基于 FuzzyPicker),支持中英文/编号搜索选择精灵
- BattleFlow configSelect 阶段替换为 SpeciesPicker,删除旧的上下翻页逻辑
- evolution 移除 ALL_SPECIES_IDS 限制,所有 Dex 物种均可进化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 15:15:19 +08:00
parent 77e8d15482
commit 1217c453c4
11 changed files with 181 additions and 156 deletions

View File

@@ -71,9 +71,11 @@ describe('checkEvolution', () => {
expect(checkEvolution(creature)).toBeNull()
})
test('pikachu cannot evolve in MVP', () => {
test('pikachu does not evolve by level-up (needs item)', () => {
const creature = makeEvolutionCreature({ speciesId: 'pikachu', level: 50 })
expect(checkEvolution(creature)).toBeNull()
// Pikachu evolves via Thunder Stone, not level-up
const result = checkEvolution(creature)
expect(result).toBeNull()
})
test('level 100 bulbasaur can still evolve (level >= minLevel)', () => {
@@ -118,7 +120,7 @@ describe('canEvolveFurther', () => {
expect(canEvolveFurther('blastoise')).toBe(false)
})
test('pikachu cannot evolve in MVP', () => {
expect(canEvolveFurther('pikachu')).toBe(false)
test('pikachu can evolve into raichu', () => {
expect(canEvolveFurther('pikachu')).toBe(true)
})
})

View File

@@ -10,16 +10,21 @@ describe('getFallbackSprite', () => {
}
})
test('returns pikachu fallback for unknown species', () => {
const sprite = getFallbackSprite('unknown' as any)
expect(sprite).toEqual(getFallbackSprite('pikachu'))
test('returns generic fallback for unknown species', () => {
const sprite = getFallbackSprite('unknowndefinitelynotarealspecies')
expect(sprite.length).toBe(5)
expect(sprite[0]).toContain('.---')
})
test('returns curated sprite for pikachu', () => {
const sprite = getFallbackSprite('pikachu')
expect(sprite[0]).toContain('/\\')
})
test('each line has consistent width', () => {
for (const id of ALL_SPECIES_IDS) {
const sprite = getFallbackSprite(id)
const widths = sprite.map(line => line.length)
// All lines should be roughly the same width
const maxWidth = Math.max(...widths)
const minWidth = Math.min(...widths)
expect(maxWidth - minWidth).toBeLessThanOrEqual(2)

View File

@@ -1,10 +1,17 @@
import { describe, test, expect } from 'bun:test'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
import { ALL_SPECIES_IDS } from '../types'
// Original 10 curated species
const CURATED = [
'bulbasaur', 'ivysaur', 'venusaur',
'charmander', 'charmeleon', 'charizard',
'squirtle', 'wartortle', 'blastoise',
'pikachu',
]
describe('SPECIES_NAMES', () => {
test('has name for every species', () => {
for (const id of ALL_SPECIES_IDS) {
test('has name for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_NAMES[id]).toBeTruthy()
}
})
@@ -15,8 +22,8 @@ describe('SPECIES_NAMES', () => {
})
describe('SPECIES_I18N', () => {
test('has i18n for every species', () => {
for (const id of ALL_SPECIES_IDS) {
test('has i18n for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_I18N[id]).toBeTruthy()
expect(SPECIES_I18N[id]!.en).toBeTruthy()
}
@@ -29,14 +36,14 @@ describe('SPECIES_I18N', () => {
})
describe('SPECIES_PERSONALITY', () => {
test('has personality for every species', () => {
for (const id of ALL_SPECIES_IDS) {
test('has personality for curated species', () => {
for (const id of CURATED) {
expect(SPECIES_PERSONALITY[id]).toBeTruthy()
}
})
test('personality is non-empty string', () => {
for (const id of ALL_SPECIES_IDS) {
test('personality is non-empty string for curated species', () => {
for (const id of CURATED) {
expect(typeof SPECIES_PERSONALITY[id]).toBe('string')
expect(SPECIES_PERSONALITY[id]!.length).toBeGreaterThan(0)
}

View File

@@ -1,8 +1,5 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
export interface EvolutionChainStep {
from: SpeciesId
@@ -18,7 +15,6 @@ export function getNextEvolution(speciesId: SpeciesId): EvolutionChainStep | und
// 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

View File

@@ -1,7 +1,7 @@
import type { SpeciesId } from '../types'
/** Default names for each species (English) */
export const SPECIES_NAMES: Record<SpeciesId, string> = {
/** Curated English names (Dex provides default names for all species) */
export const SPECIES_NAMES: Partial<Record<string, string>> = {
bulbasaur: 'Bulbasaur',
ivysaur: 'Ivysaur',
venusaur: 'Venusaur',
@@ -14,8 +14,8 @@ export const SPECIES_NAMES: Record<SpeciesId, string> = {
pikachu: 'Pikachu',
}
/** Multilingual names */
export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
/** Curated multilingual names (falls back to English from Dex) */
export const SPECIES_I18N: Partial<Record<string, Record<string, string>>> = {
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
@@ -28,8 +28,8 @@ export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
}
/** Personality descriptions for each species */
export const SPECIES_PERSONALITY: Record<SpeciesId, string> = {
/** Curated personality descriptions (falls back to empty string) */
export const SPECIES_PERSONALITY: Partial<Record<string, string>> = {
bulbasaur: 'Calm and collected, a reliable partner',
ivysaur: 'Steady growth, patient and resilient',
venusaur: 'Majestic and powerful, a natural leader',

View File

@@ -18,9 +18,9 @@ export const TO_DEX_STAT: Record<StatName, string> = {
spAtk: 'spa', spDef: 'spd', speed: 'spe',
}
/** Query species from Dex */
/** Query species from Dex (uses Dex directly for full coverage) */
export function getSpecies(id: string) {
return gen.species.get(id)
return Dex.species.get(id)
}
/** Map Dex baseStats to our StatName format */

View File

@@ -1,17 +1,41 @@
import { Dex } from '@pkmn/sim'
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) ───
// ─── Dynamic species list from @pkmn/sim Dex ───
const SUPPLEMENT: Record<SpeciesId, {
const _rawSpecies = Dex.data.Species as Record<string, { num: number; forme?: string }>
const _ids: string[] = []
for (const [id, s] of Object.entries(_rawSpecies)) {
if (s.num > 0 && Number.isInteger(s.num) && !s.forme) {
_ids.push(id)
}
}
_ids.sort((a, b) => (_rawSpecies[a]?.num ?? 9999) - (_rawSpecies[b]?.num ?? 9999))
/** All base species IDs from @pkmn/sim Dex (sorted by dex number) */
export const ALL_SPECIES_IDS: SpeciesId[] = _ids
// ─── Supplementary data (fields not provided by @pkmn/sim) ───
// Only curated entries for species with known data; defaults used for others.
interface SupplementEntry {
growthRate: GrowthRate
captureRate: number
baseHappiness: number
flavorText: string
}> = {
}
const DEFAULT_SUPPLEMENT: SupplementEntry = {
growthRate: 'medium-slow',
captureRate: 45,
baseHappiness: 70,
flavorText: '',
}
const SUPPLEMENT: Partial<Record<string, SupplementEntry>> = {
bulbasaur: {
growthRate: 'medium-slow',
captureRate: 45,
@@ -86,12 +110,11 @@ function buildEvolutionChain(speciesId: SpeciesId): SpeciesData['evolutionChain'
function buildSpeciesData(id: SpeciesId): SpeciesData {
const dex = getSpecies(id)
const sup = SUPPLEMENT[id]
const sup = SUPPLEMENT[id] ?? DEFAULT_SUPPLEMENT
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`)
}
@@ -175,17 +198,13 @@ export async function refreshAllSpeciesData(): Promise<void> {
speciesCache.clear()
}
// ─── Dex number mapping ───
// ─── Dex number mapping (dynamic) ───
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',
}
export const DEX_TO_SPECIES: Record<number, SpeciesId> = (() => {
const map: Record<number, SpeciesId> = {}
for (const id of ALL_SPECIES_IDS) {
const s = _rawSpecies[id]
if (s) map[s.num] = id
}
return map
})()

View File

@@ -2,9 +2,9 @@ import type { SpeciesId } from '../types'
/**
* Fallback ASCII art for when sprites can't be fetched.
* Simple 5-line representations of each species.
* Curated sprites for original 10 species; generic fallback for all others.
*/
const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
const FALLBACK_SPRITES: Partial<Record<string, string[]>> = {
bulbasaur: [
' _,,--.,,_ ',
' ,\' `, ',
@@ -77,9 +77,18 @@ const FALLBACK_SPRITES: Record<SpeciesId, string[]> = {
],
}
/** Generic fallback sprite for species without curated ASCII art */
const GENERIC_SPRITE: string[] = [
' .---. ',
' / o o \\ ',
' | --- | ',
' \\ / ',
' `---\' ',
]
/**
* Get fallback ASCII sprite lines for a species.
*/
export function getFallbackSprite(speciesId: SpeciesId): string[] {
return FALLBACK_SPRITES[speciesId] ?? FALLBACK_SPRITES.pikachu
return FALLBACK_SPRITES[speciesId] ?? GENERIC_SPRITE
}

View File

@@ -10,31 +10,11 @@ export const STAT_LABELS: Record<StatName, string> = {
speed: 'SPE',
}
// Species IDs (MVP 10 species)
export type SpeciesId =
| 'bulbasaur'
| 'ivysaur'
| 'venusaur'
| 'charmander'
| 'charmeleon'
| 'charizard'
| 'squirtle'
| 'wartortle'
| 'blastoise'
| 'pikachu'
// Species IDs — dynamically populated from @pkmn/sim Dex (1025 species)
export type SpeciesId = string
export const ALL_SPECIES_IDS: SpeciesId[] = [
'bulbasaur',
'ivysaur',
'venusaur',
'charmander',
'charmeleon',
'charizard',
'squirtle',
'wartortle',
'blastoise',
'pikachu',
]
// Re-exported from dex/species.ts (computed from Dex.data at module load)
export { ALL_SPECIES_IDS } from './dex/species'
// Nature (delegated to @pkmn/sim Dex.natures)
export type NatureName = string

View File

@@ -2,12 +2,12 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Box, Text } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
import { saveBuddyData } from '../core/storage'
import { createBattle, executeTurn, executeSwitch, type BattleInit } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
import { BattleConfigPanel } from './BattleConfigPanel'
import { BattleScene, type MenuPhase } from './BattleScene'
import { SpeciesPicker } from './SpeciesPicker'
import { SwitchPanel } from './SwitchPanel'
import { ItemPanel } from './ItemPanel'
import { BattleResultPanel } from './BattleResultPanel'
@@ -46,8 +46,6 @@ interface BattleFlowProps {
inputRef?: React.MutableRefObject<BattleFlowHandle | null>
}
const VISIBLE_SPECIES = 7
export function BattleFlow({ buddyData: initialData, onClose, isActive = true, inputRef }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
@@ -58,7 +56,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
const [pendingMoves, setPendingMoves] = useState<{ creatureId: string; moveId: string; moveName: string }[]>([])
const [pendingEvos, setPendingEvos] = useState<{ creatureId: string; from: SpeciesId; to: SpeciesId }[]>([])
const [replaceIndex, setReplaceIndex] = useState(0)
const [speciesIndex, setSpeciesIndex] = useState(0)
const [configCursor, setConfigCursor] = useState(0)
// ─── Battle UI state ───
@@ -272,7 +269,6 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (configCursor === 0) {
handleRandomBattle()
} else {
setSpeciesIndex(ALL_SPECIES_IDS.indexOf(opponentSpeciesId))
setPhase('configSelect')
}
}
@@ -280,19 +276,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
}
if (phase === 'configSelect') {
if (key.escape) {
setPhase('config')
} else if (key.upArrow) {
const idx = speciesIndex > 0 ? speciesIndex - 1 : ALL_SPECIES_IDS.length - 1
setSpeciesIndex(idx)
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
} else if (key.downArrow) {
const idx = speciesIndex < ALL_SPECIES_IDS.length - 1 ? speciesIndex + 1 : 0
setSpeciesIndex(idx)
setOpponentSpeciesId(ALL_SPECIES_IDS[idx]!)
} else if (key.return) {
handleStartBattle(opponentSpeciesId, buddyData.party[0] ? getActiveCreatureLevel() : 5)
}
// SpeciesPicker handles its own input via FuzzyPicker/useInput
return
}
@@ -442,7 +426,7 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
if (key.return) handleEvolutionConfirm()
return
}
}, [isActive, phase, menuPhase, cursorIndex, configCursor, speciesIndex, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
}, [isActive, phase, menuPhase, cursorIndex, configCursor, opponentSpeciesId, buddyData, battleState, battleInit, pendingMoves, pendingEvos, onClose, handleRandomBattle, handleStartBattle, handleAction, handleResultContinue, handleForcedSwitch, handleMoveLearn, handleMoveSkip, handleEvolutionConfirm, moveMainCursor])
// Expose handleInput via ref
useEffect(() => {
@@ -497,7 +481,12 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
)
case 'configSelect':
return renderSpeciesSelect()
return (
<SpeciesPicker
onSelect={(speciesId) => handleStartBattle(speciesId, getActiveCreatureLevel())}
onCancel={() => setPhase('config')}
/>
)
case 'battle': {
if (!battleState) return null
@@ -573,65 +562,4 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
default:
return null
}
// ─── Species select sub-render ───
function renderSpeciesSelect() {
const total = ALL_SPECIES_IDS.length
// Scroll window centered on selection
const halfVisible = Math.floor(VISIBLE_SPECIES / 2)
let startIdx = speciesIndex - halfVisible
if (startIdx < 0) startIdx = 0
if (startIdx + VISIBLE_SPECIES > total) startIdx = Math.max(0, total - VISIBLE_SPECIES)
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + VISIBLE_SPECIES)
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="success"
borderText={{ content: ' 选择对手 ', position: 'top', align: 'center' }}
paddingX={2}
paddingY={1}
>
{/* Scroll indicator */}
{total > VISIBLE_SPECIES && (
<Box justifyContent="center">
<Text dimColor>{startIdx > 0 ? ' ↑ 更多 ' : ''}</Text>
</Box>
)}
{visibleSpecies.map((sid) => {
const s = getSpeciesData(sid)
const isSelected = sid === opponentSpeciesId
return (
<Box key={sid}>
{isSelected ? (
<Text color="success" bold> </Text>
) : (
<Text dimColor> </Text>
)}
<Text color={isSelected ? 'claude' : 'inactive'} bold={isSelected}>
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
</Text>
{isSelected && (
<Text dimColor> Lv.{getActiveCreatureLevel()}</Text>
)}
</Box>
)
})}
{/* Scroll indicator */}
{total > VISIBLE_SPECIES && (
<Box justifyContent="center">
<Text dimColor>{startIdx + VISIBLE_SPECIES < total ? ' ↓ 更多 ' : ''}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>[] · [Enter] · [ESC] </Text>
</Box>
</Box>
)
}
}

View File

@@ -0,0 +1,79 @@
import React, { useState, useMemo, useCallback } from 'react'
import { Box, Text, FuzzyPicker } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../dex/species'
/** Pre-computed species entry for picker */
type SpeciesEntry = {
id: SpeciesId
name: string
displayName: string // zh name or English name
dexNumber: number
types: string[]
}
// Build all entries once (species data is cached internally by getSpeciesData)
const ALL_ENTRIES: SpeciesEntry[] = ALL_SPECIES_IDS.map(id => {
const data = getSpeciesData(id)
return {
id,
name: data.name,
displayName: (data.names as Record<string, string>).zh ?? data.name,
dexNumber: data.dexNumber,
types: data.types as string[],
}
})
/** Searchable species picker using FuzzyPicker */
export function SpeciesPicker({
onSelect,
onCancel,
title = '选择精灵',
}: {
onSelect: (speciesId: SpeciesId) => void
onCancel: () => void
title?: string
}) {
const [filtered, setFiltered] = useState<SpeciesEntry[]>(ALL_ENTRIES.slice(0, 50))
const handleQueryChange = useCallback((q: string) => {
if (!q.trim()) {
setFiltered(ALL_ENTRIES.slice(0, 50))
return
}
const lower = q.toLowerCase()
const matched = ALL_ENTRIES.filter(e =>
e.id.includes(lower) ||
e.name.toLowerCase().includes(lower) ||
e.displayName.includes(q) ||
String(e.dexNumber).includes(q)
)
setFiltered(matched.slice(0, 100))
}, [])
return (
<FuzzyPicker<SpeciesEntry>
title={title}
placeholder="输入名称或编号搜索…"
items={filtered}
getKey={item => item.id}
renderItem={(item, focused) => (
<Box>
<Text color={focused ? 'claude' : undefined} bold={focused}>
#{String(item.dexNumber).padStart(3, '0')} {item.displayName}
</Text>
{item.displayName !== item.name && (
<Text dimColor> {item.name}</Text>
)}
<Text color="inactive"> {item.types.join('/')}</Text>
</Box>
)}
onQueryChange={handleQueryChange}
onSelect={item => onSelect(item.id)}
onCancel={onCancel}
emptyMessage={q => `没有找到 "${q}" 相关的精灵`}
matchLabel={filtered.length < ALL_ENTRIES.length ? `${filtered.length} 个结果` : undefined}
/>
)
}