BattleMovePowerDamagePhaseStep.kt
package io.github.lishangbu.avalon.game.battle.engine.core.runtime.flow
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleAttributeKeys
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleDamageClassIds
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleStatIds
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleStatusIds
import io.github.lishangbu.avalon.game.battle.engine.core.event.StandardHookNames
import io.github.lishangbu.avalon.game.battle.engine.core.model.UnitState
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleFieldConditionSupport
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleMoveDataReader
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleRelayReader
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleStatAliasResolver
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleStatStageSupport
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.BattleStoredBoostSupport
import io.github.lishangbu.avalon.game.battle.engine.core.runtime.support.EventContextAttributeReader
import kotlin.math.floor
/**
* 威力与伤害修正 phase step。
*
* @property phaseProcessor battle hook phase 处理器。
*/
class BattleMovePowerDamagePhaseStep(
private val phaseProcessor: BattleFlowPhaseProcessor,
private val typeEffectivenessResolver: BattleTypeEffectivenessResolver = NoopBattleTypeEffectivenessResolver,
private val fixedDamageRuleResolver: BattleMoveFixedDamageRuleResolver = BattleMoveFixedDamageRuleResolver(),
private val targetCountDamageModifier: BattleMoveTargetCountDamageModifier = BattleMoveTargetCountDamageModifier(),
) : BattleMoveResolutionStep {
override val order: Int = 300
override fun execute(context: BattleMoveResolutionContext) {
val moveType = BattleMoveDataReader.readType(context.moveEffect.data)
val damageClass = BattleMoveDataReader.readDamageClass(context.moveEffect.data)
context.basePower =
resolveDynamicBasePower(
moveData = context.moveEffect.data,
attacker = context.snapshot.units[context.attackerId],
fallbackBasePower = context.basePower,
)
val basePowerResult =
phaseProcessor.processPhase(
snapshot = context.snapshot,
hookName = StandardHookNames.ON_MODIFY_BASE_POWER.value,
moveEffect = context.moveEffect,
selfId = context.attackerId,
targetId = context.targetId,
sourceId = context.sourceId,
relay = context.basePower.toDouble(),
attributes =
context.attributes +
mapOf(
BattleAttributeKeys.MOVE_TYPE to moveType,
BattleAttributeKeys.DAMAGE_CLASS to damageClass,
),
)
context.snapshot = basePowerResult.snapshot
context.basePower = BattleRelayReader.readInt(basePowerResult.relay) ?: context.basePower
val attacker = context.snapshot.units[context.attackerId]
val target = context.snapshot.units[context.targetId]
val typeMultiplier = typeEffectivenessResolver.resolve(moveType, attacker, target)
val fixedDamage = fixedDamageRuleResolver.resolve(context.moveEffect.data, attacker, target)
val usesFixedDamageRule = fixedDamage != null
val shouldComputeNativeDamage = shouldComputeDamage(context, damageClass)
if (usesFixedDamageRule) {
context.damage = requireNotNull(fixedDamage)
} else if (shouldComputeNativeDamage) {
computeBaseDamage(context, moveType, damageClass, attacker, target)?.let { computedDamage ->
context.damage = computedDamage
}
}
if (!usesFixedDamageRule && typeMultiplier > 0.0) {
context.damage =
targetCountDamageModifier.apply(
damage = context.damage,
moveData = context.moveEffect.data,
attributes = context.attributes,
)
}
if (usesFixedDamageRule) {
if (typeMultiplier <= 0.0) {
context.damage = 0
context.hitSuccessful = false
}
return
}
val damageResult =
phaseProcessor.processPhase(
snapshot = context.snapshot,
hookName = StandardHookNames.ON_MODIFY_DAMAGE.value,
moveEffect = context.moveEffect,
selfId = context.attackerId,
targetId = context.targetId,
sourceId = context.sourceId,
relay = context.damage.toDouble(),
attributes =
context.attributes +
mapOf(
BattleAttributeKeys.CRITICAL_HIT to context.criticalHit,
BattleAttributeKeys.MOVE_TYPE to moveType,
BattleAttributeKeys.DAMAGE_CLASS to damageClass,
BattleAttributeKeys.TYPE_MULTIPLIER to typeMultiplier,
),
)
context.snapshot = damageResult.snapshot
context.damage = BattleRelayReader.readInt(damageResult.relay) ?: context.damage
if (context.criticalHit) {
context.damage = floor(context.damage * CRITICAL_DAMAGE_MULTIPLIER).toInt()
}
if (shouldApplyDamageVariance(context, damageClass, shouldComputeNativeDamage) && typeMultiplier > 0.0) {
val damageRoll = resolveDamageRoll(context)
context.damageRoll = damageRoll
context.damage = floor(context.damage * damageRoll / DAMAGE_ROLL_DENOMINATOR).toInt()
}
val stabMatched =
context.basePower > 0 &&
damageClass != null &&
damageClass != BattleDamageClassIds.STATUS &&
moveType != null &&
attacker != null &&
moveType in attacker.typeIds
val baseStab = if (stabMatched) 1.5 else 1.0
val stabResult =
phaseProcessor.processPhase(
snapshot = context.snapshot,
hookName = StandardHookNames.ON_MODIFY_STAB.value,
moveEffect = context.moveEffect,
selfId = context.attackerId,
targetId = context.targetId,
sourceId = context.sourceId,
relay = baseStab,
attributes =
context.attributes +
mapOf(
BattleAttributeKeys.MOVE_TYPE to moveType,
BattleAttributeKeys.STAB_MATCHED to stabMatched,
),
)
context.snapshot = stabResult.snapshot
val stab = BattleRelayReader.readDouble(stabResult.relay) ?: baseStab
context.damage = floor(context.damage * stab).toInt()
if (typeMultiplier <= 0.0) {
context.damage = 0
context.hitSuccessful = false
return
}
context.damage = floor(context.damage * typeMultiplier).toInt()
if (context.basePower > 0 && context.damage <= 0) {
context.damage = 1
}
}
private fun shouldComputeDamage(
context: BattleMoveResolutionContext,
damageClass: String?,
): Boolean {
if (context.basePower <= 0 || damageClass == null || damageClass == BattleDamageClassIds.STATUS) {
return false
}
if (EventContextAttributeReader.readBoolean(BattleAttributeKeys.COMPUTE_DAMAGE, context.attributes) == true) {
return true
}
return context.damage <= 0
}
private fun computeBaseDamage(
context: BattleMoveResolutionContext,
moveType: String?,
damageClass: String?,
attacker: UnitState?,
target: UnitState?,
): Int? {
if (attacker == null || target == null || damageClass == null) {
return null
}
val attackStatKey = if (damageClass == BattleDamageClassIds.SPECIAL) BattleStatIds.SPECIAL_ATTACK else BattleStatIds.ATTACK
val defenseStatKey = resolveDefenseStatKey(context, damageClass)
val attackStat =
resolveModifiedBattleStat(
context = context,
unit = attacker,
statKey = attackStatKey,
hookName = StandardHookNames.ON_MODIFY_ATTACK.value,
moveType = moveType,
damageClass = damageClass,
stage = normalizedOffensiveStage(attacker, attackStatKey, context.criticalHit),
) ?: return null
val defenseStat =
resolveModifiedBattleStat(
context = context,
unit = target,
statKey = defenseStatKey,
hookName = StandardHookNames.ON_MODIFY_DEFENSE.value,
moveType = moveType,
damageClass = damageClass,
stage =
normalizedDefensiveStage(
unit = target,
statKey = defenseStatKey,
criticalHit = context.criticalHit,
ignoreBoosts = BattleMoveDataReader.readIgnoreTargetDefensiveBoosts(context.moveEffect.data) == true,
),
) ?: return null
val burnedAttack =
if (damageClass == BattleDamageClassIds.PHYSICAL && attacker.statusState?.effectId == BattleStatusIds.BURN && attacker.abilityId != GUTS_ABILITY) {
attackStat * BURN_ATTACK_MULTIPLIER
} else {
attackStat
}
val level = attacker.metadata.level ?: DEFAULT_LEVEL
val rawDamage = (((((2.0 * level) / 5.0) + 2.0) * context.basePower * burnedAttack / defenseStat) / 50.0) + 2.0
return floor(rawDamage).toInt().coerceAtLeast(1)
}
private fun shouldApplyDamageVariance(
context: BattleMoveResolutionContext,
damageClass: String?,
shouldComputeNativeDamage: Boolean,
): Boolean {
if (context.basePower <= 0 || damageClass == null || damageClass == BattleDamageClassIds.STATUS) {
return false
}
if (EventContextAttributeReader.readInt(BattleAttributeKeys.DAMAGE_ROLL, context.attributes) != null) {
return true
}
return shouldComputeNativeDamage
}
private fun resolveDamageRoll(context: BattleMoveResolutionContext): Int {
val explicitRoll = EventContextAttributeReader.readInt(BattleAttributeKeys.DAMAGE_ROLL, context.attributes)
if (explicitRoll != null) {
return explicitRoll.coerceIn(MIN_DAMAGE_ROLL, MAX_DAMAGE_ROLL)
}
val randomResult =
context.snapshot.battle.randomState
.nextInt(DAMAGE_ROLL_BOUND)
context.snapshot =
context.snapshot.copy(
battle =
context.snapshot.battle.copy(
randomState = randomResult.nextState,
),
)
return MIN_DAMAGE_ROLL + randomResult.value
}
private fun resolveModifiedBattleStat(
context: BattleMoveResolutionContext,
unit: UnitState,
statKey: String,
hookName: String,
moveType: String?,
damageClass: String,
stage: Int,
): Double? {
val baseStat = resolveStat(unit, statKey) ?: return null
val stagedStat = baseStat * BattleStatStageSupport.stageMultiplier(stage)
val hookResult =
phaseProcessor.processPhase(
snapshot = context.snapshot,
hookName = hookName,
moveEffect = context.moveEffect,
selfId = unit.id,
targetId = context.targetId,
sourceId = context.sourceId,
relay = stagedStat,
attributes =
context.attributes +
mapOf(
BattleAttributeKeys.MOVE_TYPE to moveType,
BattleAttributeKeys.DAMAGE_CLASS to damageClass,
BattleAttributeKeys.CRITICAL_HIT to context.criticalHit,
BattleAttributeKeys.STAT_KEY to statKey,
),
)
context.snapshot = hookResult.snapshot
return (BattleRelayReader.readDouble(hookResult.relay) ?: stagedStat).coerceAtLeast(1.0)
}
private fun normalizedOffensiveStage(
unit: UnitState,
statKey: String,
criticalHit: Boolean,
): Int {
val stage = resolveBoost(unit, statKey)
return if (criticalHit && stage < 0) 0 else stage
}
private fun normalizedDefensiveStage(
unit: UnitState,
statKey: String,
criticalHit: Boolean,
ignoreBoosts: Boolean = false,
): Int {
if (ignoreBoosts) {
return 0
}
val stage = resolveBoost(unit, statKey)
return if (criticalHit && stage > 0) 0 else stage
}
private fun resolveDynamicBasePower(
moveData: Map<String, Any?>,
attacker: UnitState?,
fallbackBasePower: Int,
): Int {
val base = BattleMoveDataReader.readPositiveStageBasePowerBase(moveData)
val perStage = BattleMoveDataReader.readBasePowerPerPositiveStage(moveData)
if (base != null && perStage != null && attacker != null) {
val positiveStages = BattleStoredBoostSupport.sumPositiveStages(attacker.boosts)
return (base + positiveStages * perStage).coerceAtLeast(0)
}
return fallbackBasePower
}
private fun resolveStat(
unit: UnitState,
statKey: String,
): Int? = BattleStatAliasResolver.readValue(unit.stats, statKey)
private fun resolveBoost(
unit: UnitState,
statKey: String,
): Int = BattleStatStageSupport.readStage(unit.boosts, statKey)
/**
* `Wonder Room` 生效期间,物防与特防在伤害公式中互换使用。
*
* 当前只交换 battle 计算口径,不改单位展示层原始 stat 存储,
* 这样可以把规则影响限定在真实伤害结算链。
*/
private fun resolveDefenseStatKey(
context: BattleMoveResolutionContext,
damageClass: String,
): String {
if (!BattleFieldConditionSupport.hasWonderRoom(context.snapshot)) {
return if (damageClass == BattleDamageClassIds.SPECIAL) BattleStatIds.SPECIAL_DEFENSE else BattleStatIds.DEFENSE
}
return if (damageClass == BattleDamageClassIds.SPECIAL) BattleStatIds.DEFENSE else BattleStatIds.SPECIAL_DEFENSE
}
private companion object {
private const val DEFAULT_LEVEL: Int = 50
private const val GUTS_ABILITY: String = "guts"
private const val BURN_ATTACK_MULTIPLIER: Double = 0.5
private const val CRITICAL_DAMAGE_MULTIPLIER: Double = 1.5
private const val MIN_DAMAGE_ROLL: Int = 85
private const val MAX_DAMAGE_ROLL: Int = 100
private const val DAMAGE_ROLL_BOUND: Int = 16
private const val DAMAGE_ROLL_DENOMINATOR: Double = 100.0
}
}