fix: 修复战斗系统 bug(switch 映射、async learnableMoves、类型安全)

- engine.ts: switch 动作改为映射 creatureId 到 party slot index
- settlement.ts: 改用 for-of 循环支持 async learnableMoves 检测
- types.ts: miss 事件增加 side 字段,消除 as any
- BattleFlow.tsx: handleAction 改为 async 支持 await settleBattle
- battle.test.ts: 补充缺失的 async 标记

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-22 04:12:13 +08:00
parent df61bf3852
commit 7c64199fc5
5 changed files with 56 additions and 27 deletions

View File

@@ -99,7 +99,7 @@ describe('executeTurn', () => {
})
describe('settleBattle', () => {
test('player win increments battlesWon', () => {
test('player win increments battlesWon', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
@@ -127,11 +127,11 @@ describe('settleBattle', () => {
participantIds: [creature.id],
}
const settlement = settleBattle(data, result, 'squirtle', 20)
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(1)
})
test('player loss returns unchanged data', () => {
test('player loss returns unchanged data', async () => {
const creature = makeTestCreature()
const data: BuddyData = {
version: 2,
@@ -159,7 +159,7 @@ describe('settleBattle', () => {
participantIds: [creature.id],
}
const settlement = settleBattle(data, result, 'squirtle', 20)
const settlement = await settleBattle(data, result, 'squirtle', 20)
// Loss early-returns unchanged data
expect(settlement.data.creatures[0]!.totalXp).toBe(creature.totalXp)
expect(settlement.learnableMoves).toEqual([])
@@ -255,7 +255,7 @@ describe('chooseAIMove', () => {
})
describe('settleBattle - advanced', () => {
test('player win awards XP to creature', () => {
test('player win awards XP to creature', async () => {
const creature = makeTestCreature({ level: 5 })
const data = makeTestBuddyData([creature])
const result = {
@@ -265,11 +265,11 @@ describe('settleBattle - advanced', () => {
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = settleBattle(data, result, 'squirtle', 20)
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.creatures[0]!.totalXp).toBeGreaterThan(0)
})
test('player win awards EVs (capped at 252 per stat)', () => {
test('player win awards EVs (capped at 252 per stat)', async () => {
const creature = makeTestCreature({
level: 5,
ev: { hp: 250, attack: 250, defense: 250, spAtk: 250, spDef: 250, speed: 250 },
@@ -282,13 +282,13 @@ describe('settleBattle - advanced', () => {
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = settleBattle(data, result, 'squirtle', 20)
const settlement = await settleBattle(data, result, 'squirtle', 20)
for (const stat of ['hp', 'attack', 'defense', 'spAtk', 'spDef', 'speed'] as const) {
expect(settlement.data.creatures[0]!.ev[stat]).toBeLessThanOrEqual(252)
}
})
test('player loss does not increment battlesWon', () => {
test('player loss does not increment battlesWon', async () => {
const creature = makeTestCreature()
const data = makeTestBuddyData([creature])
const result = {
@@ -298,7 +298,7 @@ describe('settleBattle - advanced', () => {
evGained: { hp: 0, attack: 0, defense: 0, spAtk: 0, spDef: 0, speed: 0 },
participantIds: [creature.id],
}
const settlement = settleBattle(data, result, 'squirtle', 20)
const settlement = await settleBattle(data, result, 'squirtle', 20)
expect(settlement.data.stats.battlesWon).toBe(0)
})
})

View File

@@ -164,7 +164,7 @@ function parseLogToEvents(log: string[]): BattleEvent[] {
} else if (line.startsWith('|-crit|')) {
events.push({ type: 'crit' })
} else if (line.startsWith('|-miss|')) {
events.push({ type: 'miss', side } as any)
events.push({ type: 'miss', side })
} else if (line.startsWith('|-status|')) {
events.push({ type: 'status', side, status: mapStatus(parts[3]) })
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
@@ -235,8 +235,13 @@ export function executeTurn(
case 'move':
p1Choice = `move ${action.moveIndex + 1}`
break
case 'switch':
p1Choice = `switch ${action.creatureId}`
case 'switch': {
// Find the party slot number for this creature (sim uses 1-based index)
const p1Pokemon: any[] = battle.p1.pokemon
const switchIdx = p1Pokemon.findIndex((p: any) => toID(p.name) === action.creatureId || p.name === action.creatureId)
p1Choice = switchIdx >= 0 ? `switch ${switchIdx + 1}` : 'move 1'
break
}
break
case 'item':
p1Choice = 'move 1' // Items handled via settlement

View File

@@ -11,16 +11,16 @@ import { Dex } from '@pkmn/sim'
/**
* Settle battle results: XP, EV, level ups, move learning, evolution detection.
*/
export function settleBattle(
export async function settleBattle(
data: BuddyData,
result: BattleResult,
opponentSpeciesId: SpeciesId,
opponentLevel: number,
): {
): Promise<{
data: BuddyData
learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
} {
}> {
if (result.winner !== 'player') {
return { data, learnableMoves: [], pendingEvolutions: [] }
}
@@ -40,10 +40,14 @@ export function settleBattle(
// Award XP/EV to participant creatures
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = []
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter(Boolean))
const participantIds = new Set(result.participantIds.length > 0 ? result.participantIds : data.party.filter((id): id is string => id !== null))
const updatedCreatures = data.creatures.map(creature => {
if (!participantIds.has(creature.id)) return creature
const updatedCreatures: typeof data.creatures = []
for (const creature of data.creatures) {
if (!participantIds.has(creature.id)) {
updatedCreatures.push(creature)
continue
}
// Award EVs (capped)
const newEv = { ...creature.ev }
@@ -61,8 +65,28 @@ export function settleBattle(
const species = getSpeciesData(creature.speciesId)
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
// Detect new learnable moves (async in real code, but for settlement we check synchronously)
// This will be handled at the UI level with getNewLearnableMoves
// Detect new learnable moves on level up
if (newLevel > oldLevel) {
const learnset = await Dex.learnsets.get(creature.speciesId)
if (learnset?.learnset) {
for (const [moveId, sources] of Object.entries(learnset.learnset)) {
for (const src of sources as string[]) {
if (src.startsWith('9L')) {
const moveLevel = parseInt(src.slice(2))
if (moveLevel > oldLevel && moveLevel <= newLevel) {
const dexMove = Dex.moves.get(moveId)
learnableMoves.push({
creatureId: creature.id,
moveId,
moveName: dexMove?.name ?? moveId,
})
}
break
}
}
}
}
}
// Detect evolution
if (newLevel > oldLevel) {
@@ -80,13 +104,13 @@ export function settleBattle(
}
}
return {
updatedCreatures.push({
...creature,
level: newLevel,
totalXp: newTotalXp,
ev: newEv,
}
})
})
}
// Update data
const updatedData: BuddyData = {

View File

@@ -39,7 +39,7 @@ export type BattleEvent =
| { type: 'switch'; side: 'player' | 'opponent'; speciesId: string; name: string }
| { type: 'effectiveness'; multiplier: number }
| { type: 'crit' }
| { type: 'miss' }
| { type: 'miss'; side: 'player' | 'opponent' }
| { type: 'status'; side: 'player' | 'opponent'; status: StatusCondition }
| { type: 'statChange'; side: 'player' | 'opponent'; stat: string; stages: number }
| { type: 'ability'; side: 'player' | 'opponent'; ability: string }

View File

@@ -65,7 +65,7 @@ export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps)
}, [buddyData])
// Battle phase: handle action
const handleAction = useCallback((action: PlayerAction) => {
const handleAction = useCallback(async (action: PlayerAction) => {
if (!battleInit) return
const state = executeTurn(battleInit, action)
setBattleState(state)
@@ -73,7 +73,7 @@ export function BattleFlow({ buddyData: initialData, onClose }: BattleFlowProps)
if (state.finished && state.result) {
const participants = buddyData.party.filter((id): id is string => id !== null)
const result = { ...state.result, participantIds: participants }
const settled = settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
const settled = await settleBattle(buddyData, result, opponentSpeciesId, opponentLevel)
setBuddyData(settled.data)
setPendingMoves(settled.learnableMoves)