feat: 集成 Battle tab 到 BuddyPanel,重命名 data/ 为 dex/ 规避 gitignore

- BuddyPanel 新增 Battle tab,BattleFlow 加 isActive 控制
- BattleFlow configSelect 阶段支持 ↑↓ 选择物种
- packages/pokemon/src/data/ → dex/,解决根 .gitignore 匹配问题
- 全量 Tab→2空格 缩进转换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 08:35:19 +08:00
parent 25067e78af
commit f22caf0e97
62 changed files with 4699 additions and 4476 deletions

View File

@@ -7,7 +7,6 @@
"types": "./src/index.ts",
"dependencies": {
"@pkmn/client": "^0.7.2",
"@pkmn/protocol": "^0.7.2",
"@pkmn/view": "^0.7.2"
"@pkmn/protocol": "^0.7.2"
}
}

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test'
import type { SpeciesId, Creature } from '../types'
import { generateCreature, calculateStats, getCreatureName, getTotalEV, recalculateLevel, getActiveCreature } from '../core/creature'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
describe('generateCreature', () => {
test('creates a creature with correct defaults', async () => {

View File

@@ -1,7 +1,7 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardEV, awardTurnEV, getEVSummary, resetEVCooldowns } from '../core/effort'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
beforeEach(() => {
resetEVCooldowns()

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { getEVForTool, DEFAULT_EV_MAPPING, MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
describe('getEVForTool', () => {
test('returns EV mapping for known tools', () => {

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test'
import { generateCreature } from '../core/creature'
import { awardXP, getXpProgress } from '../core/experience'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
describe('xpForLevel', () => {
test('level 1 requires 0 XP', () => {

View File

@@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { determineGender, getGenderSymbol } from '../core/gender'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
describe('determineGender', () => {
test('genderless species', () => {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../data/learnsets'
import { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from '../dex/learnsets'
import { EMPTY_MOVE } from '../types'
describe('getDefaultMoveset', () => {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../data/names'
import { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from '../dex/names'
import { ALL_SPECIES_IDS } from '../types'
describe('SPECIES_NAMES', () => {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { getAllNatureNames, randomNature, getNatureEffect } from '../data/nature'
import { getAllNatureNames, randomNature, getNatureEffect } from '../dex/nature'
describe('getAllNatureNames', () => {
test('returns 25 nature names', () => {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../data/pkmn'
import { FROM_DEX_STAT, TO_DEX_STAT, mapBaseStats, mapGenderRatio } from '../dex/pkmn'
describe('FROM_DEX_STAT', () => {
test('maps all 6 stats', () => {

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../data/species'
import { getSpeciesData, getAllSpeciesData, DEX_TO_SPECIES, ensureSpeciesData } from '../dex/species'
import { ALL_SPECIES_IDS } from '../types'
import type { SpeciesId } from '../types'

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../data/xpTable'
import { xpForLevel, levelFromXp, xpToNextLevel } from '../dex/xpTable'
describe('xpForLevel', () => {
test('returns 0 for level 1', () => {

View File

@@ -1,7 +1,7 @@
import { Battle, Teams, toID } from '@pkmn/sim'
import { Dex } from '@pkmn/sim'
import type { Creature, SpeciesId } from '../types'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../data/pkmn'
import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
import { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition } from './types'
import { chooseAIMove } from './ai'

View File

@@ -1,11 +1,11 @@
import type { StatName, SpeciesId } from '../types'
import { STAT_NAMES } from '../types'
import { TO_DEX_STAT } from '../data/pkmn'
import { TO_DEX_STAT } from '../dex/pkmn'
import type { BattleResult } from './types'
import type { BuddyData } from '../types'
import { levelFromXp } from '../data/xpTable'
import { getSpeciesData } from '../data/species'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../data/evMapping'
import { levelFromXp } from '../dex/xpTable'
import { getSpeciesData } from '../dex/species'
import { MAX_EV_PER_STAT, MAX_EV_TOTAL } from '../dex/evMapping'
import { Dex } from '@pkmn/sim'
/**

View File

@@ -1,12 +1,12 @@
import { randomUUID } from 'node:crypto'
import type { Creature, SpeciesId, StatName, StatsResult } from '../types'
import { STAT_NAMES } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { determineGender } from './gender'
import { levelFromXp } from '../data/xpTable'
import { gen, TO_DEX_STAT } from '../data/pkmn'
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
import { randomNature } from '../data/nature'
import { levelFromXp } from '../dex/xpTable'
import { gen, TO_DEX_STAT } from '../dex/pkmn'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
/**
* Generate a new creature of the given species.

View File

@@ -1,6 +1,6 @@
import type { Creature, StatName } from '../types'
import { STAT_NAMES } from '../types'
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../data/evMapping'
import { getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL, EV_COOLDOWN_MS } from '../dex/evMapping'
import { getTotalEV } from './creature'
// Track last EV award time per tool to enforce cooldown

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import type { BuddyData, Creature, Egg, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { generateCreature } from './creature'
import { addToParty, depositToBox } from './storage'

View File

@@ -1,6 +1,6 @@
import type { Creature, EvolutionResult, SpeciesId } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
/**
* Check if a creature meets evolution conditions.

View File

@@ -1,6 +1,6 @@
import type { Creature } from '../types'
import { getSpeciesData } from '../data/species'
import { levelFromXp, xpForLevel } from '../data/xpTable'
import { getSpeciesData } from '../dex/species'
import { levelFromXp, xpForLevel } from '../dex/xpTable'
/**
* Award XP to a creature. Returns updated creature and whether level up occurred.

View File

@@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { SpeciesId, SpriteCache } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { getSpritesDir } from './storage'
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/HRKings/pokemonsay-newgenerations/master/pokemons'

View File

@@ -4,9 +4,9 @@ import { homedir } from 'node:os'
import type { BuddyData, Creature, SpeciesId, PCBox, Bag } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { generateCreature } from './creature'
import { getSpeciesData } from '../data/species'
import { getDefaultMoveset, getDefaultAbility } from '../data/learnsets'
import { randomNature } from '../data/nature'
import { getSpeciesData } from '../dex/species'
import { getDefaultMoveset, getDefaultAbility } from '../dex/learnsets'
import { randomNature } from '../dex/nature'
const BUDDY_DATA_PATH = join(homedir(), '.claude', 'buddy-data.json')
const BUDDY_SPRITES_DIR = join(homedir(), '.claude', 'buddy-sprites')

View File

@@ -1,37 +0,0 @@
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,
}
}

View File

@@ -1,59 +0,0 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId, MoveSlot } from '../types'
import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves: { id: string; level: number }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) })
break
}
}
}
levelUpMoves.sort((a, b) => a.level - b.level)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
})
while (slots.length < 4) slots.push(EMPTY_MOVE)
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
}
/** Get the default ability for a species (first non-hidden ability) */
export function getDefaultAbility(speciesId: SpeciesId): string {
const species = Dex.species.get(speciesId)
return species?.abilities?.['0']?.toLowerCase() ?? ''
}
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return []
const result: { id: string; name: string }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
result.push({ id: moveId, name: dexMove?.name ?? moveId })
}
break
}
}
}
return result
}

View File

@@ -1,191 +0,0 @@
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

@@ -0,0 +1,31 @@
import type { StatName } from '../types'
/**
* Default EV mapping: tool name → EV gains per use.
* Tools not in this mapping get random 1-2 EV points.
*/
export const DEFAULT_EV_MAPPING: Record<string, Record<StatName, number>> = {
Bash: { hp: 0, attack: 2, defense: 0, spAtk: 0, spDef: 0, speed: 1 },
Edit: { hp: 0, attack: 0, defense: 1, spAtk: 2, spDef: 0, speed: 0 },
Write: { hp: 0, attack: 0, defense: 0, spAtk: 3, spDef: 0, speed: 0 },
Read: { hp: 1, attack: 0, defense: 2, spAtk: 0, spDef: 0, speed: 0 },
Grep: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Glob: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 1 },
Agent: { hp: 0, attack: 1, defense: 0, spAtk: 0, spDef: 0, speed: 2 },
WebSearch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
WebFetch: { hp: 1, attack: 0, defense: 0, spAtk: 0, spDef: 2, speed: 0 },
}
// EV limits (matching original Pokémon)
export const MAX_EV_PER_STAT = 252
export const MAX_EV_TOTAL = 510
// EV cooldown: same tool type only counts once per 30 seconds
export const EV_COOLDOWN_MS = 30_000
/**
* Get EV gains for a tool. Returns undefined if not mapped (→ random).
*/
export function getEVForTool(toolName: string): Record<StatName, number> | undefined {
return DEFAULT_EV_MAPPING[toolName]
}

View File

@@ -0,0 +1,37 @@
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,
}
}

View File

@@ -0,0 +1,59 @@
import { Dex } from '@pkmn/sim'
import type { SpeciesId, MoveSlot } from '../types'
import { EMPTY_MOVE } from '../types'
const GEN = 9
/** Get the default moveset for a species at a given level (last 4 level-up moves) */
export async function getDefaultMoveset(speciesId: SpeciesId, level: number): Promise<[MoveSlot, MoveSlot, MoveSlot, MoveSlot]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return [EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE, EMPTY_MOVE]
const levelUpMoves: { id: string; level: number }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
levelUpMoves.push({ id: moveId, level: parseInt(src.slice(2)) })
break
}
}
}
levelUpMoves.sort((a, b) => a.level - b.level)
const available = levelUpMoves.filter(m => m.level <= level).slice(-4)
const slots: MoveSlot[] = available.map(m => {
const dexMove = Dex.moves.get(m.id)
return { id: m.id, pp: dexMove?.pp ?? 10, maxPp: dexMove?.pp ?? 10 }
})
while (slots.length < 4) slots.push(EMPTY_MOVE)
return slots as [MoveSlot, MoveSlot, MoveSlot, MoveSlot]
}
/** Get the default ability for a species (first non-hidden ability) */
export function getDefaultAbility(speciesId: SpeciesId): string {
const species = Dex.species.get(speciesId)
return species?.abilities?.['0']?.toLowerCase() ?? ''
}
/** Get newly learnable moves when leveling up */
export async function getNewLearnableMoves(speciesId: SpeciesId, oldLevel: number, newLevel: number): Promise<{ id: string; name: string }[]> {
const learnset = await Dex.learnsets.get(speciesId)
if (!learnset?.learnset) return []
const result: { id: string; name: string }[] = []
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith(`${GEN}L`)) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
result.push({ id: moveId, name: dexMove?.name ?? moveId })
}
break
}
}
}
return result
}

View File

@@ -0,0 +1,43 @@
import type { SpeciesId } from '../types'
/** Default names for each species (English) */
export const SPECIES_NAMES: Record<SpeciesId, string> = {
bulbasaur: 'Bulbasaur',
ivysaur: 'Ivysaur',
venusaur: 'Venusaur',
charmander: 'Charmander',
charmeleon: 'Charmeleon',
charizard: 'Charizard',
squirtle: 'Squirtle',
wartortle: 'Wartortle',
blastoise: 'Blastoise',
pikachu: 'Pikachu',
}
/** Multilingual names */
export const SPECIES_I18N: Record<SpeciesId, Record<string, string>> = {
bulbasaur: { en: 'Bulbasaur', ja: 'フシギダネ', zh: '妙蛙种子' },
ivysaur: { en: 'Ivysaur', ja: 'フシギソウ', zh: '妙蛙草' },
venusaur: { en: 'Venusaur', ja: 'フシギバナ', zh: '妙蛙花' },
charmander: { en: 'Charmander', ja: 'ヒトカゲ', zh: '小火龙' },
charmeleon: { en: 'Charmeleon', ja: 'リザード', zh: '火恐龙' },
charizard: { en: 'Charizard', ja: 'リザードン', zh: '喷火龙' },
squirtle: { en: 'Squirtle', ja: 'ゼニガメ', zh: '杰尼龟' },
wartortle: { en: 'Wartortle', ja: 'カメール', zh: '卡咪龟' },
blastoise: { en: 'Blastoise', ja: 'カメックス', zh: '水箭龟' },
pikachu: { en: 'Pikachu', ja: 'ピカチュウ', zh: '皮卡丘' },
}
/** Personality descriptions for each species */
export const SPECIES_PERSONALITY: Record<SpeciesId, string> = {
bulbasaur: 'Calm and collected, a reliable partner',
ivysaur: 'Steady growth, patient and resilient',
venusaur: 'Majestic and powerful, a natural leader',
charmander: 'Energetic and curious, loves adventure',
charmeleon: 'Fierce and determined, always pushing forward',
charizard: 'Proud and strong-willed, a formidable ally',
squirtle: 'Cheerful and playful, adapts easily',
wartortle: 'Loyal and protective, wise beyond years',
blastoise: 'Steadfast and powerful, a defensive fortress',
pikachu: 'Friendly and energetic, always by your side',
}

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

@@ -0,0 +1,81 @@
import type { GrowthRate } from '../types'
/**
* Calculate total XP required to reach a given level for a growth rate type.
* Follows original Pokémon XP curve formulas.
*/
export function xpForLevel(level: number, growthRate: GrowthRate): number {
if (level <= 1) return 0
const n = level
switch (growthRate) {
case 'erratic':
return xpErratic(n)
case 'fast':
return Math.floor((n * n * n * 4) / 5)
case 'medium-fast':
return n * n * n
case 'medium-slow':
return Math.floor((6 / 5) * n * n * n - 15 * n * n + 100 * n - 140)
case 'slow':
return Math.floor((5 * n * n * n) / 4)
case 'fluctuating':
return xpFluctuating(n)
default:
return n * n * n
}
}
/**
* Calculate level from total XP for a given growth rate.
*/
export function levelFromXp(totalXp: number, growthRate: GrowthRate): number {
// Binary search for level
let lo = 1
let hi = 100
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (xpForLevel(mid, growthRate) <= totalXp) {
lo = mid
} else {
hi = mid - 1
}
}
return Math.min(lo, 100)
}
/**
* XP needed to go from current level to next level.
*/
export function xpToNextLevel(currentLevel: number, totalXp: number, growthRate: GrowthRate): number {
if (currentLevel >= 100) return 0
const nextLevelXp = xpForLevel(currentLevel + 1, growthRate)
return nextLevelXp - totalXp
}
// Erratic growth rate (complex piecewise)
function xpErratic(n: number): number {
if (n <= 1) return 0
if (n <= 50) {
return Math.floor((n * n * n * (100 - n)) / 50)
}
if (n <= 68) {
return Math.floor((n * n * n * (150 - n)) / 100)
}
if (n <= 98) {
return Math.floor((n * n * n * Math.floor((1911 - 10 * n) / 3)) / 500)
}
// n 99-100
return Math.floor((n * n * n * (160 - n)) / 100)
}
// Fluctuating growth rate (complex piecewise)
function xpFluctuating(n: number): number {
if (n <= 1) return 0
if (n <= 15) {
return Math.floor((n * n * n * (Math.floor((n + 1) / 3) + 24)) / 50)
}
if (n <= 36) {
return Math.floor((n * n * n * (n + 14)) / 50)
}
return Math.floor((n * n * n * (Math.floor(n / 2) + 32)) / 50)
}

View File

@@ -27,14 +27,14 @@ export type {
export { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS, EMPTY_MOVE } from './types'
// Data
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './data/species'
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 } from './data/evolution'
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './data/learnsets'
export { FROM_DEX_STAT, TO_DEX_STAT } from './data/pkmn'
export { SPECIES_DATA, DEX_TO_SPECIES, getSpeciesData, getAllSpeciesData, ensureSpeciesData, refreshAllSpeciesData } from './dex/species'
export { DEFAULT_EV_MAPPING, getEVForTool, MAX_EV_PER_STAT, MAX_EV_TOTAL } from './dex/evMapping'
export { xpForLevel, levelFromXp, xpToNextLevel } from './dex/xpTable'
export { SPECIES_NAMES, SPECIES_I18N, SPECIES_PERSONALITY } from './dex/names'
export { getAllNatureNames, randomNature, getNatureEffect } from './dex/nature'
export { getNextEvolution } from './dex/evolution'
export { getDefaultMoveset, getDefaultAbility, getNewLearnableMoves } from './dex/learnsets'
export { FROM_DEX_STAT, TO_DEX_STAT } from './dex/pkmn'
// Battle
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './battle/types'
@@ -77,3 +77,4 @@ export { SwitchPanel } from './ui/SwitchPanel'
export { ItemPanel } from './ui/ItemPanel'
export { BattleResultPanel } from './ui/BattleResultPanel'
export { MoveLearnPanel } from './ui/MoveLearnPanel'
export { BattleFlow } from './ui/BattleFlow'

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Creature, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { calculateStats, getCreatureName } from '../core/creature'
const CYAN = 'ansi:cyan'

View File

@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react'
import { Box, Text, useInput } 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, type BattleInit } from '../battle/engine'
import { settleBattle, applyMoveLearn, applyEvolution } from '../battle/settlement'
@@ -28,9 +29,10 @@ type Phase =
interface BattleFlowProps {
buddyData: BuddyData
onClose: () => void
isActive?: boolean
}
export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps) {
export function BattleFlow({ buddyData: initialData, onClose, isActive = true }: BattleFlowProps) {
const [phase, setPhase] = useState<Phase>('config')
const [buddyData, setBuddyData] = useState(initialData)
const [battleInit, setBattleInit] = useState<BattleInit | null>(null)
@@ -40,11 +42,13 @@ export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps)
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)
// ─── Input handling ───
useInput((input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean }) => {
// Config phase: Enter = random battle, ESC = cancel
if (!isActive) return
if (phase === 'config') {
if (key.escape) {
onClose()
@@ -273,7 +277,6 @@ export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps)
// Render by phase
switch (phase) {
case 'config':
case 'configSelect':
return (
<BattleConfigPanel
party={getPartyCreatures()}
@@ -282,6 +285,34 @@ export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps)
/>
)
case 'configSelect': {
const species = getSpeciesData(opponentSpeciesId)
const selectedIdx = ALL_SPECIES_IDS.indexOf(opponentSpeciesId)
const startIdx = Math.max(0, Math.min(selectedIdx, ALL_SPECIES_IDS.length - 5))
const visibleSpecies = ALL_SPECIES_IDS.slice(startIdx, startIdx + 5)
return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold color="ansi:cyan"> </Text>
{visibleSpecies.map((sid, i) => {
const s = getSpeciesData(sid)
const isSelected = sid === opponentSpeciesId
return (
<Box key={sid}>
<Text color={isSelected ? 'ansi:yellow' : 'ansi:white'}>
{isSelected ? ' ▶ ' : ' '}
#{String(s.dexNumber).padStart(3, '0')} {s.names.zh ?? s.name}
</Text>
{isSelected && <Text color="ansi:cyan"> Lv.{getActiveCreatureLevel()}</Text>}
</Box>
)
})}
<Box marginTop={1}>
<Text color="ansi:white"> [] [Enter] [ESC] </Text>
</Box>
</Box>
)
}
case 'battle': {
if (!battleState) return null
return (

View File

@@ -2,14 +2,14 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, Creature, SpeciesId } from '../types'
import { STAT_NAMES, STAT_LABELS } from '../types'
import { getSpeciesData } from '../data/species'
import { SPECIES_PERSONALITY } from '../data/names'
import { getSpeciesData } from '../dex/species'
import { SPECIES_PERSONALITY } from '../dex/names'
import { calculateStats, getCreatureName, getTotalEV } from '../core/creature'
import { getXpProgress } from '../core/experience'
import { getEVSummary } from '../core/effort'
import { getGenderSymbol } from '../core/gender'
import { getStatColor } from './shared'
import { getNextEvolution } from '../data/evolution'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
interface CompanionCardProps {

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId } from '../types'
import { getSpeciesData } from '../data/species'
import { getSpeciesData } from '../dex/species'
import { loadSprite } from '../core/spriteCache'
import { getFallbackSprite } from '../sprites/fallback'

View File

@@ -2,8 +2,8 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { BuddyData, SpeciesId } from '../types'
import { ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
const CYAN: Color = 'ansi:cyan'
const GREEN: Color = 'ansi:green'

View File

@@ -2,8 +2,8 @@ import React from 'react'
import { Box, Text, type Color } from '@anthropic/ink'
import type { SpeciesId, StatName } from '../types'
import { STAT_NAMES, STAT_LABELS, ALL_SPECIES_IDS } from '../types'
import { getSpeciesData } from '../data/species'
import { getNextEvolution } from '../data/evolution'
import { getSpeciesData } from '../dex/species'
import { getNextEvolution } from '../dex/evolution'
import { StatBar } from './StatBar'
import { getStatColor } from './shared'

View File

@@ -21,6 +21,7 @@ import { getXpProgress } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
import { StatBar, SpriteAnimator, getFallbackSprite, loadSprite } from '@claude-code-best/pokemon';
import { BattleFlow, loadBuddyData } from '@claude-code-best/pokemon';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -91,6 +92,13 @@ export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps)
onClose={() => onClose('buddy panel closed')}
/>
</Tab>,
<Tab key="battle" title="Battle">
<BattleTab
buddyData={data}
isActive={selectedTab === 'Battle'}
onUpdate={updateData}
/>
</Tab>,
<Tab key="egg" title="Egg">
<EggTab buddyData={data} />
</Tab>,
@@ -613,6 +621,35 @@ function DexTab({
);
}
// ─── Battle Tab ──────────────────────────────────────────
function BattleTab({
buddyData,
isActive,
onUpdate,
}: {
buddyData: BuddyData;
isActive: boolean;
onUpdate: (data: BuddyData) => void;
}) {
const [battleKey, setBattleKey] = useState(0);
const handleClose = async () => {
const updated = await loadBuddyData();
onUpdate(updated);
setBattleKey(k => k + 1);
};
return (
<BattleFlow
key={battleKey}
buddyData={buddyData}
onClose={handleClose}
isActive={isActive}
/>
);
}
// ─── Egg Tab ──────────────────────────────────────────
function EggTab({ buddyData }: { buddyData: BuddyData }) {