BattleUnitAssembler.kt
package io.github.lishangbu.avalon.game.battle.engine.core.unit
import io.github.lishangbu.avalon.game.battle.engine.core.model.AttachedEffectState
import io.github.lishangbu.avalon.game.battle.engine.core.model.UnitDebugState
import io.github.lishangbu.avalon.game.battle.engine.core.model.UnitMetadataState
import io.github.lishangbu.avalon.game.battle.engine.core.model.UnitState
import io.github.lishangbu.avalon.game.calculator.growthrate.GrowthRateCalculatorFactory
import io.github.lishangbu.avalon.game.calculator.stat.StatCalculatorFactory
data class CreatureAbilityOptionRecord(
val internalName: String,
val slot: Int,
val hidden: Boolean,
)
data class CreatureUnitImportRecord(
val id: Long,
val speciesId: Long?,
val internalName: String,
val name: String,
val weight: Int?,
val growthRateInternalName: String?,
val captureRate: Int?,
val typeIds: List<String>,
val baseStats: Map<String, Int>,
val abilityOptions: List<CreatureAbilityOptionRecord>,
)
data class NatureImportRecord(
val id: Long?,
val internalName: String,
val increasedStatInternalName: String?,
val decreasedStatInternalName: String?,
)
data class BattleMoveSlotInput(
val moveId: String,
val currentPp: Int? = null,
)
data class BattleUnitAssemblyRequest(
val unitId: String,
val metadata: UnitMetadataState,
val abilityInternalName: String? = null,
val itemId: String? = null,
val moves: List<BattleMoveSlotInput> = emptyList(),
val currentHp: Int? = null,
val statusState: AttachedEffectState? = null,
val volatileStates: Map<String, AttachedEffectState> = emptyMap(),
val conditionStates: Map<String, AttachedEffectState> = emptyMap(),
val boosts: Map<String, Int> = emptyMap(),
val debugState: UnitDebugState = UnitDebugState(),
val forceSwitchRequested: Boolean = false,
)
data class BattleUnitAssemblyResult(
val unit: UnitState,
val creatureId: Long,
val creatureInternalName: String,
val creatureName: String,
val level: Int,
val requiredExperience: Int,
val calculatedStats: Map<String, Int>,
)
object BattleUnitAssembler {
private const val DEFAULT_IV: Int = 31
private const val DEFAULT_EV: Int = 0
private const val NEUTRAL_NATURE: Int = 100
private const val INCREASED_NATURE: Int = 110
private const val DECREASED_NATURE: Int = 90
fun assemble(
request: BattleUnitAssemblyRequest,
creature: CreatureUnitImportRecord,
nature: NatureImportRecord?,
movePpDefaults: Map<String, Int>,
statCalculatorFactory: StatCalculatorFactory,
growthRateCalculatorFactory: GrowthRateCalculatorFactory,
): BattleUnitAssemblyResult {
val level = requireNotNull(request.metadata.level) { "Battle unit metadata.level must not be null." }
require(level in 1..100) { "Battle unit level must be between 1 and 100." }
val calculatedStats =
creature.baseStats.mapValues { (statInternalName, baseStat) ->
statCalculatorFactory.calculateStat(
internalName = statInternalName,
base = baseStat,
dv = request.metadata.ivs[statInternalName] ?: DEFAULT_IV,
stateExp = request.metadata.evs[statInternalName] ?: DEFAULT_EV,
level = level,
nature = natureModifier(statInternalName, nature),
)
}
val maxHp = (calculatedStats["hp"] ?: 0).coerceAtLeast(1)
val currentHp = (request.currentHp ?: maxHp).coerceIn(0, maxHp)
val selectedAbilityId = selectAbilityId(request.abilityInternalName, creature.abilityOptions)
val requiredExperience =
growthRateCalculatorFactory.calculateGrowthRate(
internalName = creature.growthRateInternalName.orEmpty(),
level = level,
)
return BattleUnitAssemblyResult(
unit =
UnitState(
id = request.unitId,
currentHp = currentHp,
maxHp = maxHp,
statusState = request.statusState,
abilityId = selectedAbilityId,
itemId = request.itemId,
typeIds = creature.typeIds.toSet(),
volatileStates = normalizeStateMap(request.volatileStates),
conditionStates = normalizeStateMap(request.conditionStates),
boosts = request.boosts,
stats = calculatedStats,
movePp =
request.moves.associate { slot ->
val defaultPp = movePpDefaults[slot.moveId] ?: 0
slot.moveId to (slot.currentPp ?: defaultPp)
},
metadata =
UnitMetadataState(
creatureId = creature.id,
creatureSpeciesId = creature.speciesId,
creatureInternalName = creature.internalName,
creatureName = creature.name,
level = level,
requiredExperience = requiredExperience,
natureId = nature?.id,
captureRate = creature.captureRate,
weight = creature.weight,
captureContext = request.metadata.captureContext,
ivs = request.metadata.ivs,
evs = request.metadata.evs,
),
debugState = request.debugState,
forceSwitchRequested = request.forceSwitchRequested,
),
creatureId = creature.id,
creatureInternalName = creature.internalName,
creatureName = creature.name,
level = level,
requiredExperience = requiredExperience,
calculatedStats = calculatedStats,
)
}
private fun natureModifier(
statInternalName: String,
nature: NatureImportRecord?,
): Int =
when (statInternalName) {
nature?.increasedStatInternalName -> INCREASED_NATURE
nature?.decreasedStatInternalName -> DECREASED_NATURE
else -> NEUTRAL_NATURE
}
private fun selectAbilityId(
requestedAbilityInternalName: String?,
abilityOptions: List<CreatureAbilityOptionRecord>,
): String? {
if (requestedAbilityInternalName != null) {
return requireNotNull(
abilityOptions.firstOrNull { option -> option.internalName == requestedAbilityInternalName }?.internalName,
) {
"Creature does not provide ability '$requestedAbilityInternalName'."
}
}
return abilityOptions
.sortedWith(compareBy<CreatureAbilityOptionRecord> { option -> option.hidden }.thenBy { option -> option.slot })
.firstOrNull()
?.internalName
}
/**
* 规范化导入入口传入的挂载效果状态表。
*
* 设计意图:
* - 以 `AttachedEffectState.effectId` 为唯一真值来源,不信任外层 map key;
* - 按 `effectOrder` 固定遍历顺序,避免导入结果受 map 实现细节影响;
* - 导入边界直接接收完整 runtime state,不再做额外字符串镜像提升。
*/
private fun normalizeStateMap(states: Map<String, AttachedEffectState>): Map<String, AttachedEffectState> =
states.values
.sortedBy(AttachedEffectState::effectOrder)
.associateBy(AttachedEffectState::effectId)
}