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

View File

@@ -164,7 +164,7 @@ function parseLogToEvents(log: string[]): BattleEvent[] {
} else if (line.startsWith('|-crit|')) { } else if (line.startsWith('|-crit|')) {
events.push({ type: 'crit' }) events.push({ type: 'crit' })
} else if (line.startsWith('|-miss|')) { } else if (line.startsWith('|-miss|')) {
events.push({ type: 'miss', side } as any) events.push({ type: 'miss', side })
} else if (line.startsWith('|-status|')) { } else if (line.startsWith('|-status|')) {
events.push({ type: 'status', side, status: mapStatus(parts[3]) }) events.push({ type: 'status', side, status: mapStatus(parts[3]) })
} else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) { } else if (line.startsWith('|-boost|') || line.startsWith('|-unboost|')) {
@@ -235,8 +235,13 @@ export function executeTurn(
case 'move': case 'move':
p1Choice = `move ${action.moveIndex + 1}` p1Choice = `move ${action.moveIndex + 1}`
break break
case 'switch': case 'switch': {
p1Choice = `switch ${action.creatureId}` // 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 break
case 'item': case 'item':
p1Choice = 'move 1' // Items handled via settlement 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. * Settle battle results: XP, EV, level ups, move learning, evolution detection.
*/ */
export function settleBattle( export async function settleBattle(
data: BuddyData, data: BuddyData,
result: BattleResult, result: BattleResult,
opponentSpeciesId: SpeciesId, opponentSpeciesId: SpeciesId,
opponentLevel: number, opponentLevel: number,
): { ): Promise<{
data: BuddyData data: BuddyData
learnableMoves: { creatureId: string; moveId: string; moveName: string }[] learnableMoves: { creatureId: string; moveId: string; moveName: string }[]
pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[]
} { }> {
if (result.winner !== 'player') { if (result.winner !== 'player') {
return { data, learnableMoves: [], pendingEvolutions: [] } return { data, learnableMoves: [], pendingEvolutions: [] }
} }
@@ -40,10 +40,14 @@ export function settleBattle(
// Award XP/EV to participant creatures // Award XP/EV to participant creatures
const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = [] const learnableMoves: { creatureId: string; moveId: string; moveName: string }[] = []
const pendingEvolutions: { creatureId: string; from: SpeciesId; to: SpeciesId }[] = [] 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 => { const updatedCreatures: typeof data.creatures = []
if (!participantIds.has(creature.id)) return creature for (const creature of data.creatures) {
if (!participantIds.has(creature.id)) {
updatedCreatures.push(creature)
continue
}
// Award EVs (capped) // Award EVs (capped)
const newEv = { ...creature.ev } const newEv = { ...creature.ev }
@@ -61,8 +65,28 @@ export function settleBattle(
const species = getSpeciesData(creature.speciesId) const species = getSpeciesData(creature.speciesId)
const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate)) const newLevel = Math.min(100, levelFromXp(newTotalXp, species.growthRate))
// Detect new learnable moves (async in real code, but for settlement we check synchronously) // Detect new learnable moves on level up
// This will be handled at the UI level with getNewLearnableMoves 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 // Detect evolution
if (newLevel > oldLevel) { if (newLevel > oldLevel) {
@@ -80,13 +104,13 @@ export function settleBattle(
} }
} }
return { updatedCreatures.push({
...creature, ...creature,
level: newLevel, level: newLevel,
totalXp: newTotalXp, totalXp: newTotalXp,
ev: newEv, ev: newEv,
} })
}) }
// Update data // Update data
const updatedData: BuddyData = { const updatedData: BuddyData = {

View File

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

View File

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