Files
claude-code/packages/pokemon/src/battle/engine.ts
claude-code-best a3fc348421 feat: Phase 2 — 战斗引擎 @pkmn 薄适配层
- 安装 @pkmn/protocol @pkmn/client @pkmn/view
- 新建 battle/types.ts: BattleState, BattlePokemon, BattleEvent, PlayerAction 等
- 新建 battle/adapter.ts: Creature→PokemonSet 转换, 野生对手生成
- 新建 battle/engine.ts: createBattle() + executeTurn() 薄封装 @pkmn/sim
- 新建 battle/handler.ts: @pkmn/protocol Handler→BattleEvent 转换
- 新建 battle/ai.ts: 随机合法招式 AI 决策
- 新建 battle/settlement.ts: 战后结算 XP/EV/升级/进化/招式学习
- 新建 battle/index.ts: 统一导出

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 00:31:42 +08:00

286 lines
9.7 KiB
TypeScript

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 { STAT_NAMES } from '../types'
import type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
import { chooseAIMove } from './ai'
// ─── Adapter: Creature → Showdown Set ───
function creatureToSetString(creature: Creature): string {
const species = Dex.species.get(creature.speciesId)
if (!species) throw new Error(`Species ${creature.speciesId} not found`)
const natureName = creature.nature.charAt(0).toUpperCase() + creature.nature.slice(1)
const abilityName = creature.ability ? (Dex.abilities.get(creature.ability)?.name ?? creature.ability) : ''
const moves = creature.moves
.filter(m => m.id)
.map(m => Dex.moves.get(m.id)?.name ?? m.id)
const ivs = STAT_NAMES.map(s => `${creature.iv[s]} ${TO_DEX_STAT[s].toUpperCase().replace('SPA', 'SpA').replace('SPD', 'SpD')}`).join(' / ')
const evs = STAT_NAMES.map(s => `${creature.ev[s]} ${TO_DEX_STAT[s].toUpperCase().replace('SPA', 'SpA').replace('SPD', 'SpD')}`).join(' / ')
const lines = [
species.name,
`Level: ${creature.level}`,
`Ability: ${abilityName}`,
`Nature: ${natureName}`,
`IVs: ${ivs}`,
`EVs: ${evs}`,
]
if (creature.heldItem) lines.push(`Item: ${Dex.items.get(creature.heldItem)?.name ?? creature.heldItem}`)
for (const move of moves) lines.push(`- ${move}`)
return lines.join('\n')
}
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
const species = Dex.species.get(speciesId)
if (!species) throw new Error(`Species ${speciesId} not found`)
const ability = species.abilities['0'] ?? ''
// Get first 4 level-up moves (from species data)
const moves = getSpeciesMoves(speciesId, level)
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
}
function getSpeciesMoves(speciesId: string, level: number): string[] {
// Use @pkmn/sim move pool - get natural level-up moves
// This is a simplified approach for the sim
const species = Dex.species.get(speciesId)
if (!species) return ['Tackle']
// For sim battles, just return basic moves
return ['Tackle', 'Splash']
}
// ─── State Projection ───
function projectPokemon(pkm: any): BattlePokemon {
if (!pkm) throw new Error('No active pokemon')
const species = pkm.species
const hp = pkm.hp ?? 0
const maxHp = pkm.maxhp ?? 1
return {
id: pkm.name, // sim doesn't store our UUID, use name as temp id
speciesId: toID(species.name) as SpeciesId,
name: species.name,
level: pkm.level,
hp,
maxHp,
types: species.types?.map((t: string) => t.toLowerCase()) ?? [],
moves: (pkm.moveSlots ?? pkm.baseMoveset ?? []).filter(Boolean).map((m: any) => ({
id: toID(m.name ?? m),
name: m.name ?? m,
type: m.type ?? 'Normal',
pp: m.pp ?? 0,
maxPp: m.maxPp ?? m.pp ?? 0,
disabled: m.disabled ?? false,
})),
ability: pkm.ability ?? '',
heldItem: pkm.item ?? null,
status: mapStatus(pkm.status),
statStages: projectBoosts(pkm.boosts),
}
}
function mapStatus(status: string): StatusCondition {
if (!status) return 'none'
const s = status.toLowerCase()
if (s === 'psn') return 'poison'
if (s === 'tox') return 'bad_poison'
if (s === 'brn') return 'burn'
if (s === 'par') return 'paralysis'
if (s === 'frz') return 'freeze'
if (s === 'slp') return 'sleep'
return 'none'
}
function projectBoosts(boosts: Record<string, number> | undefined): Record<string, number> {
if (!boosts) return {}
const result: Record<string, number> = {}
for (const [k, v] of Object.entries(boosts)) {
const mapped = FROM_DEX_STAT[k]
if (mapped) result[mapped] = v
else result[k] = v
}
return result
}
// ─── Log Parsing ───
function parseLogToEvents(log: string[]): BattleEvent[] {
const events: BattleEvent[] = []
for (const line of log) {
if (line.startsWith('|move|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
const moveName = parts[3]
events.push({ type: 'move', side, move: moveName, user: parts[2] })
} else if (line.startsWith('|-damage|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
const hpStr = parts[3] // e.g. "16/20" or "16/20[1]"
const [cur, max] = parseHpString(hpStr)
events.push({ type: 'damage', side, amount: 0, percentage: Math.round((1 - cur / max) * 100) })
} else if (line.startsWith('|-heal|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
const hpStr = parts[3]
const [cur, max] = parseHpString(hpStr)
events.push({ type: 'heal', side, amount: 0, percentage: Math.round(cur / max * 100) })
} else if (line.startsWith('|faint|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
events.push({ type: 'faint', side, speciesId: toID(parts[2]?.split(': ')?.[1] ?? '') })
} else if (line.startsWith('|switch|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
const speciesPart = parts[3]?.split(',')[0]?.split(': ')
events.push({ type: 'switch', side, speciesId: toID(speciesPart?.[1] ?? ''), name: speciesPart?.[1] ?? '' })
} else if (line.startsWith('|-supereffective|')) {
events.push({ type: 'effectiveness', multiplier: 2 })
} else if (line.startsWith('|-resisted|')) {
events.push({ type: 'effectiveness', multiplier: 0.5 })
} else if (line.startsWith('|-crit|')) {
events.push({ type: 'crit' })
} else if (line.startsWith('|-miss|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
events.push({ type: 'miss', side } as any)
} else if (line.startsWith('|-status|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
const stages = line.startsWith('|-boost|') ? parseInt(parts[4]) : -parseInt(parts[4])
events.push({ type: 'statChange', side, stat: parts[3], stages })
} else if (line.startsWith('|-ability|')) {
const parts = line.split('|')
const side = parts[2]?.startsWith('p1a') ? 'player' : 'opponent'
events.push({ type: 'ability', side, ability: parts[3] })
} else if (line.startsWith('|turn|')) {
events.push({ type: 'turn', number: parseInt(line.split('|')[2]) })
}
}
return events
}
function parseHpString(hpStr: string): [number, number] {
if (!hpStr) return [0, 1]
// Remove status suffix like "[1]"
const clean = hpStr.replace(/\[.*\]/, '')
const parts = clean.split('/')
if (parts.length !== 2) return [0, 1]
return [parseInt(parts[0]) || 0, parseInt(parts[1]) || 1]
}
// ─── Engine ───
export type BattleInit = {
battle: any // @pkmn/sim Battle instance
state: BattleState
}
export function createBattle(
partyCreatures: Creature[],
opponentSpeciesId: SpeciesId,
opponentLevel: number,
_bagItems?: { id: string; count: number }[],
): BattleInit {
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
const p1Team = Teams.import(p1Sets.join('\n\n'))
const p2Team = Teams.import(p2Set)
// Create battle
const battle = new Battle({
formatid: 'gen9customgame' as any,
p1: { name: 'Player', team: p1Team },
p2: { name: 'Opponent', team: p2Team },
})
// Handle team preview → auto-select leads
battle.makeChoices('team 1', 'team 1')
// Project initial state
const state = projectState(battle, _bagItems)
return { battle, state }
}
export function executeTurn(
battleInit: BattleInit,
action: PlayerAction,
): BattleState {
const { battle } = battleInit
const prevLogLen = battle.log.length
// Build choice string
let p1Choice: string
switch (action.type) {
case 'move':
p1Choice = `move ${action.moveIndex + 1}`
break
case 'switch':
p1Choice = `switch ${action.creatureId}`
break
case 'item':
p1Choice = 'move 1' // Items handled via settlement
break
default:
p1Choice = 'move 1'
}
// AI choice
const aiPokemon = projectPokemon(battle.p2.active[0])
const aiMoveIndex = chooseAIMove(aiPokemon)
const p2Choice = `move ${aiMoveIndex + 1}`
// Execute
battle.makeChoices(p1Choice, p2Choice)
// Parse new log entries
const newLog = battle.log.slice(prevLogLen)
const newEvents = parseLogToEvents(newLog)
// Project new state
const state = projectState(battle, battleInit.state.usableItems)
state.events = [...battleInit.state.events, ...newEvents]
// Check for battle end
if (battle.ended) {
state.finished = true
const winner = battle.winner === 'Player' ? 'player' : 'opponent'
state.result = {
winner,
turns: state.turn,
xpGained: 0, // calculated in settlement
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [],
}
}
battleInit.state = state
return state
}
function projectState(battle: any, bagItems?: { id: string; count: number }[]): BattleState {
const p1 = battle.p1
const p2 = battle.p2
return {
playerPokemon: projectPokemon(p1.active[0]),
opponentPokemon: projectPokemon(p2.active[0]),
playerParty: p1.pokemon.map((p: any) => projectPokemon(p)),
opponentParty: p2.pokemon.map((p: any) => projectPokemon(p)),
turn: battle.turn ?? 1,
events: [],
finished: battle.ended,
usableItems: bagItems?.filter(i => i.count > 0).map(i => ({ id: i.id, name: i.id, count: i.count })) ?? [],
}
}