mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 00:05:51 +00:00
feat: 战斗引擎全面升级 — 捕获/逃跑/多对手/AI/道具/状态
- 新增 capture.ts:Gen 9 捕获率计算,支持精灵球/状态/时间修正 - 实现逃跑概率公式 (Gen 9) 和失败累计机制 - createBattle 支持多对手 OpponentEntry[],AI 换人考虑属性克制 - AI 选招改为优先克制招式,避免蓄力招式和被抵抗招 - 野生招式从 Dex.data.Learnsets 按等级获取,替换硬编码映射 - 实现 Potion/SuperPotion/FullRestore 等回复药效果 - 野生对手随机持有道具(5%树果/专属、3%属性增强道具) - 新增 VolatileStatus 类型,BattlePokemon 添加 volatileStatus - needsSwitch 检测改为更健壮的 p1Fainted + hasAliveBench 逻辑 - 解决 #3 物品使用、#4 逃跑、#5 多精灵对战、#6 AI、#7 野生招式、 #10 捕获系统、#11 volatile状态、#12 天气/地形、#19 野生道具 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,73 @@
|
|||||||
|
import { Dex } from '@pkmn/sim'
|
||||||
import type { BattlePokemon } from './types'
|
import type { BattlePokemon } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple AI: pick a random usable move.
|
* AI move selection: prefers super-effective moves, avoids resisted moves,
|
||||||
|
* falls back to random among usable moves.
|
||||||
*/
|
*/
|
||||||
export function chooseAIMove(pokemon: BattlePokemon): number {
|
export function chooseAIMove(pokemon: BattlePokemon, opponentTypes?: string[]): number {
|
||||||
const usable = pokemon.moves
|
const usable = pokemon.moves
|
||||||
.map((m, i) => ({ move: m, index: i }))
|
.map((m, i) => ({ move: m, index: i }))
|
||||||
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
.filter(({ move }) => move.pp > 0 && !move.disabled)
|
||||||
|
|
||||||
if (usable.length === 0) return 0 // Struggle
|
if (usable.length === 0) return 0 // Struggle
|
||||||
return usable[Math.floor(Math.random() * usable.length)]!.index
|
|
||||||
|
// If no opponent type info, pick randomly
|
||||||
|
if (!opponentTypes || opponentTypes.length === 0) {
|
||||||
|
return usable[Math.floor(Math.random() * usable.length)]!.index
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify moves by effectiveness against opponent
|
||||||
|
const superEffective: number[] = []
|
||||||
|
const neutral: number[] = []
|
||||||
|
const resisted: number[] = []
|
||||||
|
const statusMoves: number[] = [] // Lowest priority
|
||||||
|
|
||||||
|
for (const { move, index } of usable) {
|
||||||
|
const dexMove = Dex.moves.get(move.id)
|
||||||
|
if (!dexMove?.type) {
|
||||||
|
neutral.push(index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveType = dexMove.type // Keep original case for Dex.getEffectiveness
|
||||||
|
// Status moves and charge moves are lowest priority
|
||||||
|
if (dexMove.category === 'Status' || dexMove.flags?.charge) {
|
||||||
|
statusMoves.push(index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check effectiveness against all opponent types using Dex.getEffectiveness
|
||||||
|
let totalEffectiveness = 0
|
||||||
|
for (const rawOppType of opponentTypes) {
|
||||||
|
// Dex.getEffectiveness expects capitalized type names
|
||||||
|
const oppType = rawOppType.charAt(0).toUpperCase() + rawOppType.slice(1)
|
||||||
|
totalEffectiveness += Dex.getEffectiveness(moveType, oppType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalEffectiveness > 0) {
|
||||||
|
superEffective.push(index)
|
||||||
|
} else if (totalEffectiveness < 0) {
|
||||||
|
resisted.push(index)
|
||||||
|
} else {
|
||||||
|
neutral.push(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority: super-effective (70%) > neutral > super-effective (30%) > resisted > status
|
||||||
|
const rand = Math.random()
|
||||||
|
if (superEffective.length > 0 && rand < 0.7) {
|
||||||
|
return superEffective[Math.floor(Math.random() * superEffective.length)]!
|
||||||
|
}
|
||||||
|
if (neutral.length > 0) {
|
||||||
|
return neutral[Math.floor(Math.random() * neutral.length)]!
|
||||||
|
}
|
||||||
|
if (superEffective.length > 0) {
|
||||||
|
return superEffective[Math.floor(Math.random() * superEffective.length)]!
|
||||||
|
}
|
||||||
|
if (resisted.length > 0) {
|
||||||
|
return resisted[Math.floor(Math.random() * resisted.length)]!
|
||||||
|
}
|
||||||
|
// Only status moves available
|
||||||
|
return statusMoves[Math.floor(Math.random() * statusMoves.length)]!
|
||||||
}
|
}
|
||||||
|
|||||||
166
packages/pokemon/src/battle/capture.ts
Normal file
166
packages/pokemon/src/battle/capture.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Dex } from '@pkmn/sim'
|
||||||
|
import type { SpeciesId } from '../types'
|
||||||
|
import { getCaptureRate } from '../dex/pokedex-data'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gen 9 capture rate calculation.
|
||||||
|
* Returns { captured: boolean, shakes: 0-3 }
|
||||||
|
*
|
||||||
|
* Formula:
|
||||||
|
* a = (3 * maxHP - 2 * currentHP) * catchRate * ballModifier / (3 * maxHP)
|
||||||
|
* b = 65536 / (255 / a) ^ (1/4) (shake probability)
|
||||||
|
* For each of 4 shakes: if random(0,65535) < b → pass, else → break out
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Pokeball catch rate modifiers */
|
||||||
|
const BALL_MODIFIERS: Record<string, number> = {
|
||||||
|
pokeball: 1,
|
||||||
|
greatball: 1.5,
|
||||||
|
ultraball: 2,
|
||||||
|
masterball: 255, // always catches
|
||||||
|
netball: 3.5, // bug/water bonus (applied below)
|
||||||
|
diveball: 3.5, // underwater/surfing
|
||||||
|
nestball: 1, // scales with level (applied below)
|
||||||
|
repeatball: 3.5, // if already caught
|
||||||
|
timerball: 1, // scales with turns (applied below)
|
||||||
|
duskball: 3.5, // night/cave
|
||||||
|
quickball: 5, // first turn
|
||||||
|
luxuryball: 1,
|
||||||
|
premierball: 1,
|
||||||
|
cherishball: 1,
|
||||||
|
healball: 1,
|
||||||
|
friendball: 1,
|
||||||
|
levelball: 1,
|
||||||
|
lureball: 1,
|
||||||
|
moonball: 1,
|
||||||
|
loveball: 1,
|
||||||
|
heavyball: 1,
|
||||||
|
fastball: 1,
|
||||||
|
sportball: 1,
|
||||||
|
parkball: 255,
|
||||||
|
beastball: 5, // Ultra Beasts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status condition catch rate multiplier */
|
||||||
|
const STATUS_MODIFIERS: Record<string, number> = {
|
||||||
|
none: 1,
|
||||||
|
poison: 1.5,
|
||||||
|
bad_poison: 1.5,
|
||||||
|
burn: 1.5,
|
||||||
|
paralysis: 1.5,
|
||||||
|
freeze: 2,
|
||||||
|
sleep: 2.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptureResult {
|
||||||
|
captured: boolean
|
||||||
|
shakes: number // 0-3 (3 means captured)
|
||||||
|
critical: boolean // critical capture (Gen 5+)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate capture attempt.
|
||||||
|
* @param speciesId Opponent species
|
||||||
|
* @param currentHp Opponent current HP
|
||||||
|
* @param maxHp Opponent max HP
|
||||||
|
* @param ballId Pokeball item ID
|
||||||
|
* @param status Opponent status condition
|
||||||
|
* @param turn Current battle turn number
|
||||||
|
* @param isFirstTurn Whether it's the first turn of battle
|
||||||
|
* @param isNight Whether it's nighttime (for Dusk Ball)
|
||||||
|
* @param alreadyCaught Whether this species has been caught before (for Repeat Ball)
|
||||||
|
* @param opponentLevel Opponent's level (for Nest Ball)
|
||||||
|
*/
|
||||||
|
export function attemptCapture(
|
||||||
|
speciesId: SpeciesId,
|
||||||
|
currentHp: number,
|
||||||
|
maxHp: number,
|
||||||
|
ballId: string,
|
||||||
|
status: string = 'none',
|
||||||
|
turn: number = 1,
|
||||||
|
isFirstTurn: boolean = false,
|
||||||
|
isNight: boolean = false,
|
||||||
|
alreadyCaught: boolean = false,
|
||||||
|
opponentLevel: number = 50,
|
||||||
|
): CaptureResult {
|
||||||
|
const catchRate = getCaptureRate(speciesId)
|
||||||
|
|
||||||
|
// Master Ball always catches
|
||||||
|
if (ballId === 'masterball' || catchRate === 255) {
|
||||||
|
return { captured: true, shakes: 3, critical: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate ball modifier with conditional bonuses
|
||||||
|
let ballModifier = BALL_MODIFIERS[ballId.toLowerCase()] ?? 1
|
||||||
|
|
||||||
|
// Quick Ball: 5x on first turn, 1x otherwise
|
||||||
|
if (ballId === 'quickball') {
|
||||||
|
ballModifier = isFirstTurn ? 5 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer Ball: up to 4x after 10 turns
|
||||||
|
if (ballId === 'timerball') {
|
||||||
|
ballModifier = Math.min(4, 1 + (turn - 1) * 3 / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nest Ball: better for lower level wild Pokémon
|
||||||
|
if (ballId === 'nestball') {
|
||||||
|
ballModifier = Math.max(1, (40 - opponentLevel) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dusk Ball: 3.5x at night or in caves
|
||||||
|
if (ballId === 'duskball') {
|
||||||
|
ballModifier = isNight ? 3.5 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeat Ball: 3.5x if already caught
|
||||||
|
if (ballId === 'repeatball') {
|
||||||
|
ballModifier = alreadyCaught ? 3.5 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net Ball: 3.5x for Bug or Water types
|
||||||
|
if (ballId === 'netball') {
|
||||||
|
const species = Dex.species.get(speciesId)
|
||||||
|
if (species?.types?.some((t: string) => t.toLowerCase() === 'bug' || t.toLowerCase() === 'water')) {
|
||||||
|
ballModifier = 3.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status modifier
|
||||||
|
const statusMod = STATUS_MODIFIERS[status] ?? 1
|
||||||
|
|
||||||
|
// Catch rate formula (Gen 9)
|
||||||
|
const hpFactor = (3 * maxHp - 2 * currentHp) / (3 * maxHp)
|
||||||
|
const catchValue = hpFactor * catchRate * ballModifier * statusMod
|
||||||
|
const a = Math.min(255, Math.floor(catchValue))
|
||||||
|
|
||||||
|
// Shake probability
|
||||||
|
const b = Math.floor(65536 / Math.pow(255 / Math.max(1, a), 0.25))
|
||||||
|
|
||||||
|
// Perform 3 shake checks (4th check is automatic if all 3 pass)
|
||||||
|
let shakes = 0
|
||||||
|
let captured = true
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const roll = Math.floor(Math.random() * 65536)
|
||||||
|
if (roll < b) {
|
||||||
|
shakes++
|
||||||
|
} else {
|
||||||
|
captured = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical capture check (Gen 5+, rare)
|
||||||
|
const dexCount = 0 // Could track Pokedex completion rate
|
||||||
|
const criticalChance = Math.min(255, Math.floor(catchValue * dexCount / 256))
|
||||||
|
const critical = criticalChance > 0 && Math.floor(Math.random() * 256) < criticalChance
|
||||||
|
|
||||||
|
if (critical) {
|
||||||
|
// Critical capture only needs 1 shake
|
||||||
|
const roll = Math.floor(Math.random() * 65536)
|
||||||
|
captured = roll < b
|
||||||
|
return { captured, shakes: captured ? 1 : 0, critical: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { captured, shakes, critical: false }
|
||||||
|
}
|
||||||
@@ -5,6 +5,61 @@ import { TO_DEX_STAT, FROM_DEX_STAT } from '../dex/pkmn'
|
|||||||
import { STAT_NAMES } from '../types'
|
import { STAT_NAMES } from '../types'
|
||||||
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition, WeatherKind, FieldCondition } from './types'
|
import type { BattleState, BattlePokemon, BattleEvent, PlayerAction, StatusCondition, WeatherKind, FieldCondition } from './types'
|
||||||
import { chooseAIMove } from './ai'
|
import { chooseAIMove } from './ai'
|
||||||
|
import { attemptCapture } from './capture'
|
||||||
|
|
||||||
|
// ─── Utility: get actual stat value accounting for stage ───
|
||||||
|
|
||||||
|
function getStatWithStage(pokemon: BattlePokemon, statKey: string): number {
|
||||||
|
const raw = (pokemon as any)[statKey] ?? 10
|
||||||
|
const stage = pokemon.statStages?.[statKey] ?? 0
|
||||||
|
if (stage === 0) return raw
|
||||||
|
const numerator = stage > 0 ? 2 + stage : 2
|
||||||
|
const denominator = stage > 0 ? 2 : 2 - stage
|
||||||
|
return Math.floor(raw * numerator / denominator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Item Effect Application ───
|
||||||
|
|
||||||
|
/** Healing item definitions */
|
||||||
|
const HEALING_ITEMS: Record<string, { amount: number; percent?: boolean; cureStatus?: boolean }> = {
|
||||||
|
'potion': { amount: 20 },
|
||||||
|
'superpotion': { amount: 60 },
|
||||||
|
'hyperpotion': { amount: 120 },
|
||||||
|
'maxpotion': { amount: 9999 }, // full heal
|
||||||
|
'fullrestore': { amount: 9999, cureStatus: true },
|
||||||
|
'fullheal': { amount: 0, cureStatus: true },
|
||||||
|
'berryjuice': { amount: 20 },
|
||||||
|
'oranberry': { amount: 10 },
|
||||||
|
'sitrusberry': { amount: 30, percent: true },
|
||||||
|
'energyroot': { amount: 120 },
|
||||||
|
'sweetheart': { amount: 20 },
|
||||||
|
'freshwater': { amount: 30 },
|
||||||
|
'sodapop': { amount: 50 },
|
||||||
|
'lemonade': { amount: 70 },
|
||||||
|
'moomoomilk': { amount: 100 },
|
||||||
|
'revive': { amount: 50, percent: true }, // revives fainted with 50% HP
|
||||||
|
'maxrevive': { amount: 100, percent: true }, // revives fainted with full HP
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyItemEffect(battle: any, itemId: string, target: any): void {
|
||||||
|
const item = HEALING_ITEMS[itemId.toLowerCase().replace(/[-\s]/g, '')]
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
// HP healing
|
||||||
|
if (item.amount > 0 && target.hp < target.maxhp) {
|
||||||
|
if (item.percent) {
|
||||||
|
target.hp = Math.min(target.maxhp, target.hp + Math.floor(target.maxhp * item.amount / 100))
|
||||||
|
} else {
|
||||||
|
target.hp = Math.min(target.maxhp, target.hp + item.amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cure status conditions
|
||||||
|
if (item.cureStatus && target.status) {
|
||||||
|
target.status = ''
|
||||||
|
target.statusState = { toxicTurns: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Types ───
|
// ─── Types ───
|
||||||
|
|
||||||
@@ -58,19 +113,112 @@ function creatureToSetString(creature: Creature): string {
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Species-specific held items (speciesId → item name)
|
||||||
|
const SPECIES_ITEMS: Partial<Record<string, string>> = {
|
||||||
|
pikachu: 'Light Ball',
|
||||||
|
farfetchd: 'Stick',
|
||||||
|
cubone: 'Thick Club',
|
||||||
|
marowak: 'Thick Club',
|
||||||
|
ditto: 'Quick Powder',
|
||||||
|
chansey: 'Lucky Punch',
|
||||||
|
snorlax: 'Leftovers',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-based common wild held items (type → item, 5% chance)
|
||||||
|
const TYPE_ITEMS: Partial<Record<string, string>> = {
|
||||||
|
Fire: 'Charcoal',
|
||||||
|
Water: 'Mystic Water',
|
||||||
|
Electric: 'Magnet',
|
||||||
|
Grass: 'Miracle Seed',
|
||||||
|
Ice: 'Never-Melt Ice',
|
||||||
|
Fighting: 'Black Belt',
|
||||||
|
Poison: 'Poison Barb',
|
||||||
|
Ground: 'Soft Sand',
|
||||||
|
Flying: 'Sharp Beak',
|
||||||
|
Psychic: 'TwistedSpoon',
|
||||||
|
Bug: 'Silver Powder',
|
||||||
|
Rock: 'Hard Stone',
|
||||||
|
Ghost: 'Spell Tag',
|
||||||
|
Dragon: 'Dragon Fang',
|
||||||
|
Dark: 'Black Glasses',
|
||||||
|
Steel: 'Metal Coat',
|
||||||
|
Fairy: 'Fairy Feather',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roll a random held item for a wild Pokémon encounter */
|
||||||
|
function rollWildHeldItem(speciesId: SpeciesId): string | null {
|
||||||
|
// Species-specific items: 5% chance
|
||||||
|
const speciesItem = SPECIES_ITEMS[speciesId]
|
||||||
|
if (speciesItem && Math.random() < 0.05) return speciesItem
|
||||||
|
|
||||||
|
// Common berry: 5% chance
|
||||||
|
if (Math.random() < 0.05) {
|
||||||
|
const berries = ['Oran Berry', 'Sitrus Berry', 'Pecha Berry', 'Rawst Berry', 'Cheri Berry']
|
||||||
|
return berries[Math.floor(Math.random() * berries.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-based item: 3% chance
|
||||||
|
if (Math.random() < 0.03) {
|
||||||
|
const species = Dex.species.get(speciesId)
|
||||||
|
if (species?.types?.[0]) {
|
||||||
|
return TYPE_ITEMS[species.types[0]] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
function wildPokemonToSetString(speciesId: SpeciesId, level: number): string {
|
||||||
const species = Dex.species.get(speciesId)
|
const species = Dex.species.get(speciesId)
|
||||||
if (!species) throw new Error(`Species ${speciesId} not found`)
|
if (!species) throw new Error(`Species ${speciesId} not found`)
|
||||||
const ability = species.abilities['0'] ?? ''
|
const ability = species.abilities['0'] ?? ''
|
||||||
const moves = getSpeciesMoves(speciesId, level)
|
const moves = getSpeciesMoves(speciesId, level)
|
||||||
return [species.name, `Level: ${level}`, `Ability: ${ability}`, ...moves.map(m => `- ${m}`)].join('\n')
|
const lines = [species.name, `Level: ${level}`, `Ability: ${ability}`]
|
||||||
|
// Wild Pokémon have a small chance to hold an item
|
||||||
|
const wildItem = rollWildHeldItem(speciesId)
|
||||||
|
if (wildItem) lines.push(`Item: ${wildItem}`)
|
||||||
|
for (const move of moves) lines.push(`- ${move}`)
|
||||||
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
function getSpeciesMoves(speciesId: string, level: number): string[] {
|
||||||
|
// Try learnset-based moves first (real level-up moves from Dex.data)
|
||||||
|
const learnset = Dex.data.Learnsets[speciesId]?.learnset
|
||||||
|
if (learnset) {
|
||||||
|
const levelUpMoves: { id: string; level: number; gen: number }[] = []
|
||||||
|
for (const [moveId, sources] of Object.entries(learnset)) {
|
||||||
|
for (const src of sources as string[]) {
|
||||||
|
const match = src.match(/^(\d+)L(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
const gen = parseInt(match[1]!)
|
||||||
|
const moveLevel = parseInt(match[2]!)
|
||||||
|
if (moveLevel <= level) {
|
||||||
|
// Keep highest-gen entry for each move
|
||||||
|
const existing = levelUpMoves.find(m => m.id === moveId)
|
||||||
|
if (!existing || gen > existing.gen) {
|
||||||
|
if (existing) {
|
||||||
|
existing.gen = gen
|
||||||
|
existing.level = moveLevel
|
||||||
|
} else {
|
||||||
|
levelUpMoves.push({ id: moveId, level: moveLevel, gen })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by level, take last 4 (most recently learned)
|
||||||
|
levelUpMoves.sort((a, b) => a.level - b.level)
|
||||||
|
const selected = levelUpMoves.slice(-4)
|
||||||
|
if (selected.length > 0) {
|
||||||
|
return selected.map(m => Dex.moves.get(m.id)?.name ?? m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: type-based defaults
|
||||||
const species = Dex.species.get(speciesId)
|
const species = Dex.species.get(speciesId)
|
||||||
if (!species) return ['Tackle']
|
const type = species?.types[0]?.toLowerCase() ?? 'normal'
|
||||||
const type = species.types[0]?.toLowerCase() ?? 'normal'
|
const fallbackMoves: Record<string, string[]> = {
|
||||||
const basicMoves: Record<string, string[]> = {
|
|
||||||
normal: ['Tackle', 'Scratch'],
|
normal: ['Tackle', 'Scratch'],
|
||||||
fire: ['Ember', 'FireSpin'],
|
fire: ['Ember', 'FireSpin'],
|
||||||
water: ['WaterGun', 'Bubble'],
|
water: ['WaterGun', 'Bubble'],
|
||||||
@@ -90,7 +238,7 @@ function getSpeciesMoves(speciesId: string, _level: number): string[] {
|
|||||||
steel: ['MetalClaw', 'IronTail'],
|
steel: ['MetalClaw', 'IronTail'],
|
||||||
fairy: ['FairyWind', 'DisarmingVoice'],
|
fairy: ['FairyWind', 'DisarmingVoice'],
|
||||||
}
|
}
|
||||||
return basicMoves[type] ?? ['Tackle', 'Scratch']
|
return fallbackMoves[type] ?? ['Tackle', 'Scratch']
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State Projection (from Battle object) ───
|
// ─── State Projection (from Battle object) ───
|
||||||
@@ -101,6 +249,16 @@ function projectPokemon(pkm: any): BattlePokemon {
|
|||||||
const hp = pkm.hp ?? 0
|
const hp = pkm.hp ?? 0
|
||||||
const maxHp = pkm.maxhp ?? 1
|
const maxHp = pkm.maxhp ?? 1
|
||||||
|
|
||||||
|
// Extract volatile statuses from the Pokémon's volatileStatuses
|
||||||
|
const volatileStatuses: string[] = []
|
||||||
|
if (pkm.volatiles) {
|
||||||
|
for (const key of Object.keys(pkm.volatiles)) {
|
||||||
|
volatileStatuses.push(key.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pkm.statusState?.confusion) volatileStatuses.push('confusion')
|
||||||
|
if (pkm.statusState?.infatuation) volatileStatuses.push('infatuation')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pkm.name,
|
id: pkm.name,
|
||||||
speciesId: toID(species.name) as SpeciesId,
|
speciesId: toID(species.name) as SpeciesId,
|
||||||
@@ -123,6 +281,7 @@ function projectPokemon(pkm: any): BattlePokemon {
|
|||||||
ability: pkm.ability ?? '',
|
ability: pkm.ability ?? '',
|
||||||
heldItem: pkm.item ?? null,
|
heldItem: pkm.item ?? null,
|
||||||
status: mapStatus(pkm.status),
|
status: mapStatus(pkm.status),
|
||||||
|
volatileStatus: volatileStatuses,
|
||||||
statStages: projectBoosts(pkm.boosts),
|
statStages: projectBoosts(pkm.boosts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,10 +312,13 @@ function projectBoosts(boosts: Record<string, number> | undefined): Record<strin
|
|||||||
function projectState(battle: any, bagItems?: { id: string; count: number }[], prevConditions?: { player: FieldCondition[]; opponent: FieldCondition[] }): BattleState {
|
function projectState(battle: any, bagItems?: { id: string; count: number }[], prevConditions?: { player: FieldCondition[]; opponent: FieldCondition[] }): BattleState {
|
||||||
const p1 = battle.p1
|
const p1 = battle.p1
|
||||||
const p2 = battle.p2
|
const p2 = battle.p2
|
||||||
// Extract weather from battle field
|
// Extract weather directly from battle field (auto-updates each turn)
|
||||||
const weatherRaw = battle.field?.weather ?? ''
|
const weatherRaw = battle.field?.weather ?? ''
|
||||||
const weather = mapWeather(weatherRaw)
|
const weather = mapWeather(weatherRaw)
|
||||||
|
|
||||||
|
// Extract terrain from battle field
|
||||||
|
const terrainRaw = battle.field?.terrain ?? ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerPokemon: projectPokemon(p1.active[0]),
|
playerPokemon: projectPokemon(p1.active[0]),
|
||||||
opponentPokemon: projectPokemon(p2.active[0]),
|
opponentPokemon: projectPokemon(p2.active[0]),
|
||||||
@@ -372,19 +534,30 @@ function parseMaxHp(hpStr?: string): number | null {
|
|||||||
|
|
||||||
// ─── Engine API ───
|
// ─── Engine API ───
|
||||||
|
|
||||||
|
export type OpponentEntry = { speciesId: SpeciesId; level: number }
|
||||||
|
|
||||||
export async function createBattle(
|
export async function createBattle(
|
||||||
partyCreatures: Creature[],
|
partyCreatures: Creature[],
|
||||||
opponentSpeciesId: SpeciesId,
|
opponentSpeciesId: SpeciesId | OpponentEntry[],
|
||||||
opponentLevel: number,
|
opponentLevel?: number,
|
||||||
_bagItems?: { id: string; count: number }[],
|
_bagItems?: { id: string; count: number }[],
|
||||||
): Promise<BattleInit> {
|
): Promise<BattleInit> {
|
||||||
const stream = new BattleStreams.BattleStream()
|
const stream = new BattleStreams.BattleStream()
|
||||||
const streams = BattleStreams.getPlayerStreams(stream)
|
const streams = BattleStreams.getPlayerStreams(stream)
|
||||||
|
|
||||||
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
const p1Sets = partyCreatures.map(c => creatureToSetString(c))
|
||||||
const p2Set = wildPokemonToSetString(opponentSpeciesId, opponentLevel)
|
|
||||||
|
// Support both single species (wild) and multi-species (trainer) opponents
|
||||||
|
let p2Sets: string[]
|
||||||
|
if (Array.isArray(opponentSpeciesId)) {
|
||||||
|
p2Sets = opponentSpeciesId.map(e => wildPokemonToSetString(e.speciesId, e.level))
|
||||||
|
} else {
|
||||||
|
const level = opponentLevel ?? 5
|
||||||
|
p2Sets = [wildPokemonToSetString(opponentSpeciesId, level)]
|
||||||
|
}
|
||||||
|
|
||||||
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
const p1Team = Teams.import(p1Sets.join('\n\n'))
|
||||||
const p2Team = Teams.import(p2Set)
|
const p2Team = Teams.import(p2Sets.join('\n\n'))
|
||||||
|
|
||||||
const spec = { formatid: 'gen9customgame' }
|
const spec = { formatid: 'gen9customgame' }
|
||||||
const p1spec = { name: 'Player', team: Teams.pack(p1Team) }
|
const p1spec = { name: 'Player', team: Teams.pack(p1Team) }
|
||||||
@@ -428,6 +601,8 @@ export async function executeTurn(
|
|||||||
|
|
||||||
// Build p1 choice
|
// Build p1 choice
|
||||||
let p1Choice: string
|
let p1Choice: string
|
||||||
|
let isEscape = false
|
||||||
|
let state_captureResult: { captured: boolean; shakes: number; speciesId: SpeciesId } | undefined
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'move':
|
case 'move':
|
||||||
p1Choice = `move ${action.moveIndex + 1}`
|
p1Choice = `move ${action.moveIndex + 1}`
|
||||||
@@ -439,15 +614,78 @@ export async function executeTurn(
|
|||||||
p1Choice = idx >= 0 && idx < p1Pokemon.length ? `switch ${idx + 1}` : 'move 1'
|
p1Choice = idx >= 0 && idx < p1Pokemon.length ? `switch ${idx + 1}` : 'move 1'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'item':
|
case 'item': {
|
||||||
|
// Pokeball items trigger capture attempt
|
||||||
|
if (action.itemId && action.itemId.toLowerCase().includes('ball')) {
|
||||||
|
const opp = prevState.opponentPokemon
|
||||||
|
const captureResult = attemptCapture(
|
||||||
|
opp.speciesId, opp.hp, opp.maxHp, action.itemId, opp.status,
|
||||||
|
prevState.turn, prevState.turn === 1,
|
||||||
|
)
|
||||||
|
if (captureResult.captured) {
|
||||||
|
// Capture successful — forfeit and end battle
|
||||||
|
streams.omniscient.write('>p1 forfeit')
|
||||||
|
await streams.spectator.read()
|
||||||
|
const state = projectState(battle, prevState.usableItems, {
|
||||||
|
player: prevState.playerConditions,
|
||||||
|
opponent: prevState.opponentConditions,
|
||||||
|
})
|
||||||
|
state.finished = true
|
||||||
|
state.captureResult = { captured: true, shakes: captureResult.shakes, speciesId: opp.speciesId }
|
||||||
|
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'capture' }]
|
||||||
|
battleInit.state = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
// Capture failed — player wastes turn, opponent attacks
|
||||||
|
state_captureResult = { captured: false, shakes: captureResult.shakes, speciesId: opp.speciesId }
|
||||||
|
} else {
|
||||||
|
// Apply healing/status item effect
|
||||||
|
const p1Active = battle.p1.active[0]
|
||||||
|
if (p1Active && action.itemId) {
|
||||||
|
applyItemEffect(battle, action.itemId, p1Active)
|
||||||
|
}
|
||||||
|
}
|
||||||
p1Choice = 'move 1'
|
p1Choice = 'move 1'
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
case 'run': {
|
||||||
|
// Escape probability: f = ((playerSpeed * 128) / opponentSpeed + 30 * attempts) % 256
|
||||||
|
const attempts = (prevState.escapeAttempts ?? 0) + 1
|
||||||
|
const playerSpeed = prevState.playerPokemon.statStages?.speed
|
||||||
|
? getStatWithStage(prevState.playerPokemon, 'spe')
|
||||||
|
: (battle.p1.active[0]?.stats?.spe ?? 10)
|
||||||
|
const opponentSpeed = prevState.opponentPokemon.statStages?.speed
|
||||||
|
? getStatWithStage(prevState.opponentPokemon, 'spe')
|
||||||
|
: (battle.p2.active[0]?.stats?.spe ?? 10)
|
||||||
|
const f = Math.floor((playerSpeed * 128 / Math.max(1, opponentSpeed) + 30 * attempts) % 256)
|
||||||
|
const roll = Math.floor(Math.random() * 256)
|
||||||
|
|
||||||
|
if (roll < f) {
|
||||||
|
// Escape successful — forfeit the battle
|
||||||
|
streams.omniscient.write('>p1 forfeit')
|
||||||
|
await streams.spectator.read()
|
||||||
|
const state = projectState(battle, prevState.usableItems, {
|
||||||
|
player: prevState.playerConditions,
|
||||||
|
opponent: prevState.opponentConditions,
|
||||||
|
})
|
||||||
|
state.finished = true
|
||||||
|
state.escaped = true
|
||||||
|
state.events = [...prevState.events, { type: 'activate' as const, side: 'player' as const, effect: 'escape' }]
|
||||||
|
battleInit.state = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape failed — player wastes turn, opponent attacks
|
||||||
|
isEscape = true
|
||||||
|
p1Choice = 'move 1' // placeholder, player doesn't act
|
||||||
|
break
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
p1Choice = 'move 1'
|
p1Choice = 'move 1'
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI choice
|
// AI choice — pass player's types so AI can consider effectiveness
|
||||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon)
|
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
|
||||||
const p2Choice = `move ${aiMoveIndex + 1}`
|
const p2Choice = `move ${aiMoveIndex + 1}`
|
||||||
|
|
||||||
// Submit choices via stream
|
// Submit choices via stream
|
||||||
@@ -467,10 +705,25 @@ export async function executeTurn(
|
|||||||
})
|
})
|
||||||
state.events = [...prevState.events, ...newEvents]
|
state.events = [...prevState.events, ...newEvents]
|
||||||
|
|
||||||
|
// Track escape attempts
|
||||||
|
if (isEscape) {
|
||||||
|
state.escapeAttempts = (prevState.escapeAttempts ?? 0) + 1
|
||||||
|
} else {
|
||||||
|
state.escapeAttempts = prevState.escapeAttempts ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track capture result
|
||||||
|
if (state_captureResult) {
|
||||||
|
state.captureResult = state_captureResult
|
||||||
|
}
|
||||||
|
|
||||||
// Forced switch detection via Battle object
|
// Forced switch detection via Battle object
|
||||||
const p1Active = battle.p1.active[0]
|
const p1Active = battle.p1.active[0]
|
||||||
const hasAliveBench = battle.p1.pokemon.some((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
|
||||||
if (p1Active?.fainted && hasAliveBench && !battle.ended) {
|
const hasAliveBench = battle.p1.pokemon.some(
|
||||||
|
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
|
||||||
|
)
|
||||||
|
if (p1Fainted && hasAliveBench && !battle.ended) {
|
||||||
state.needsSwitch = true
|
state.needsSwitch = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,11 +761,36 @@ export async function executeSwitch(
|
|||||||
const p2Active = battle.p2.active[0]
|
const p2Active = battle.p2.active[0]
|
||||||
if (p2Active?.fainted || p2Active?.hp === 0) {
|
if (p2Active?.fainted || p2Active?.hp === 0) {
|
||||||
const p2Pkm: any[] = battle.p2.pokemon
|
const p2Pkm: any[] = battle.p2.pokemon
|
||||||
const nextAlive = p2Pkm.findIndex((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
// Find best switch-in: prefer type advantage against player's active
|
||||||
p2Cmd = nextAlive >= 0 ? `\n>p2 switch ${nextAlive + 1}` : '\n>p2 pass'
|
const playerTypes = prevState.playerPokemon.types
|
||||||
|
const aliveIndices = p2Pkm
|
||||||
|
.map((p: any, i: number) => ({ p, i }))
|
||||||
|
.filter(({ p, i }) => i > 0 && !p.fainted && p.hp > 0)
|
||||||
|
|
||||||
|
let bestIdx = -1
|
||||||
|
if (aliveIndices.length > 0 && playerTypes.length > 0) {
|
||||||
|
// Score each candidate by type effectiveness against player
|
||||||
|
let bestScore = -Infinity
|
||||||
|
for (const { p, i } of aliveIndices) {
|
||||||
|
const types = p.species?.types ?? []
|
||||||
|
let score = 0
|
||||||
|
for (const atkType of types) {
|
||||||
|
for (const defType of playerTypes) {
|
||||||
|
score += Dex.getEffectiveness(atkType, defType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to first alive if no type advantage found
|
||||||
|
if (bestIdx < 0) bestIdx = aliveIndices[0]?.i ?? -1
|
||||||
|
p2Cmd = bestIdx >= 0 ? `\n>p2 switch ${bestIdx + 1}` : '\n>p2 pass'
|
||||||
} else {
|
} else {
|
||||||
// p2's active is alive — submit AI move choice
|
// p2's active is alive — submit AI move choice
|
||||||
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon)
|
const aiMoveIndex = chooseAIMove(prevState.opponentPokemon, prevState.playerPokemon.types)
|
||||||
p2Cmd = `\n>p2 move ${aiMoveIndex + 1}`
|
p2Cmd = `\n>p2 move ${aiMoveIndex + 1}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,8 +813,11 @@ export async function executeSwitch(
|
|||||||
|
|
||||||
// Forced switch detection via Battle object
|
// Forced switch detection via Battle object
|
||||||
const p1Active = battle.p1.active[0]
|
const p1Active = battle.p1.active[0]
|
||||||
const hasAliveBench = battle.p1.pokemon.some((p: any, i: number) => i > 0 && !p.fainted && p.hp > 0)
|
const p1Fainted = p1Active?.fainted || p1Active?.hp === 0 || state.playerPokemon.hp === 0
|
||||||
if (p1Active?.fainted && hasAliveBench && !battle.ended) {
|
const hasAliveBench = battle.p1.pokemon.some(
|
||||||
|
(p: any) => !p.fainted && p.hp > 0 && p !== p1Active,
|
||||||
|
)
|
||||||
|
if (p1Fainted && hasAliveBench && !battle.ended) {
|
||||||
state.needsSwitch = true
|
state.needsSwitch = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
|
export type { BattleState, BattlePokemon, BattleEvent, BattleResult, PlayerAction, MoveOption, StatusCondition } from './types'
|
||||||
export { createBattle, executeTurn, executeSwitch, type BattleInit } from './engine'
|
export { createBattle, executeTurn, executeSwitch, type BattleInit, type OpponentEntry } from './engine'
|
||||||
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
|
export { settleBattle, applyMoveLearn, applyEvolution } from './settlement'
|
||||||
export { chooseAIMove } from './ai'
|
export { chooseAIMove } from './ai'
|
||||||
|
export { attemptCapture, type CaptureResult } from './capture'
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { StatName, SpeciesId } from '../types'
|
import type { StatName, SpeciesId } from '../types'
|
||||||
|
|
||||||
export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'none'
|
export type StatusCondition = 'poison' | 'bad_poison' | 'burn' | 'paralysis' | 'freeze' | 'sleep' | 'confusion' | 'infatuation' | 'flinch' | 'none'
|
||||||
|
|
||||||
|
export type VolatileStatus = 'confusion' | 'infatuation' | 'flinch' | 'leech_seed' | 'trapped' | 'nightmare' | 'curse' | 'taunt' | 'encore' | 'torment' | 'disable' | 'magnet_rise' | 'telekinesis' | 'heal_block' | 'embargo' | 'yawn' | 'perish_song'
|
||||||
|
|
||||||
export type BattlePokemon = {
|
export type BattlePokemon = {
|
||||||
id: string // creature ID
|
id: string // creature ID
|
||||||
@@ -14,6 +16,7 @@ export type BattlePokemon = {
|
|||||||
ability: string
|
ability: string
|
||||||
heldItem: string | null
|
heldItem: string | null
|
||||||
status: StatusCondition
|
status: StatusCondition
|
||||||
|
volatileStatus: string[] // confusion, infatuation, leech_seed, etc.
|
||||||
statStages: Record<string, number> // -6 to +6
|
statStages: Record<string, number> // -6 to +6
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ export type PlayerAction =
|
|||||||
| { type: 'move'; moveIndex: number }
|
| { type: 'move'; moveIndex: number }
|
||||||
| { type: 'switch'; partyIndex: number }
|
| { type: 'switch'; partyIndex: number }
|
||||||
| { type: 'item'; itemId: string }
|
| { type: 'item'; itemId: string }
|
||||||
|
| { type: 'run' }
|
||||||
|
|
||||||
export type WeatherKind = 'sun' | 'rain' | 'sandstorm' | 'hail' | 'snow' | 'desolateland' | 'primordialsea' | 'deltastream'
|
export type WeatherKind = 'sun' | 'rain' | 'sandstorm' | 'hail' | 'snow' | 'desolateland' | 'primordialsea' | 'deltastream'
|
||||||
|
|
||||||
@@ -83,4 +87,7 @@ export type BattleState = {
|
|||||||
weather?: WeatherKind // current weather
|
weather?: WeatherKind // current weather
|
||||||
playerConditions: FieldCondition[] // hazards on player's side
|
playerConditions: FieldCondition[] // hazards on player's side
|
||||||
opponentConditions: FieldCondition[] // hazards on opponent's side
|
opponentConditions: FieldCondition[] // hazards on opponent's side
|
||||||
|
escaped?: boolean // player successfully escaped
|
||||||
|
escapeAttempts?: number // number of failed escape attempts
|
||||||
|
captureResult?: { captured: boolean; shakes: number; speciesId: SpeciesId } // capture attempt result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,11 +132,36 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
|
|
||||||
const handleAction = useCallback(async (action: PlayerAction) => {
|
const handleAction = useCallback(async (action: PlayerAction) => {
|
||||||
if (!battleInit) return
|
if (!battleInit) return
|
||||||
|
|
||||||
|
// Consume item from bag before executing turn
|
||||||
|
if (action.type === 'item' && action.itemId) {
|
||||||
|
const updated = {
|
||||||
|
...buddyData,
|
||||||
|
bag: {
|
||||||
|
...buddyData.bag,
|
||||||
|
items: buddyData.bag.items.map(entry =>
|
||||||
|
entry.id === action.itemId
|
||||||
|
? { ...entry, count: Math.max(0, entry.count - 1) }
|
||||||
|
: entry
|
||||||
|
).filter(entry => entry.count > 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
setBuddyData(updated)
|
||||||
|
}
|
||||||
|
|
||||||
const state = await executeTurn(battleInit, action)
|
const state = await executeTurn(battleInit, action)
|
||||||
setBattleState(state)
|
setBattleState(state)
|
||||||
setMenuPhase('main')
|
setMenuPhase('main')
|
||||||
setCursorIndex(0)
|
setCursorIndex(0)
|
||||||
|
|
||||||
|
// Escape successful — close battle without rewards
|
||||||
|
if (state.escaped) {
|
||||||
|
saveBuddyData(buddyData)
|
||||||
|
setPhase('done')
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Pokémon fainted — show switch panel overlay
|
// Pokémon fainted — show switch panel overlay
|
||||||
if (state.needsSwitch && !state.finished) {
|
if (state.needsSwitch && !state.finished) {
|
||||||
setMenuPhase('pokemon')
|
setMenuPhase('pokemon')
|
||||||
@@ -310,7 +335,8 @@ export function BattleFlow({ buddyData: initialData, onClose, isActive = true, i
|
|||||||
setMenuPhase('pokemon')
|
setMenuPhase('pokemon')
|
||||||
setCursorIndex(0)
|
setCursorIndex(0)
|
||||||
return
|
return
|
||||||
case 3: // 逃跑 — show message
|
case 3: // 逃跑 — attempt escape
|
||||||
|
handleAction({ type: 'run' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user