BattleSessionRunAttemptResolver.kt
package io.github.lishangbu.avalon.game.battle.engine.core.session
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleItemIds
import io.github.lishangbu.avalon.game.battle.engine.core.constant.BattleStatIds
import io.github.lishangbu.avalon.game.battle.engine.core.model.BattleType
import io.github.lishangbu.avalon.game.battle.engine.core.model.SideState
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.BattleHeldItemRuntimeSupport
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 kotlin.math.floor
import kotlin.math.roundToInt
/**
* session 逃跑判定解析器。
*
* 设计意图:
* - 把逃跑公式、阻断条件和速度推导从 `BattleSessionActionExecutionSupport` 中拆出;
* - 让 run action 执行只保留“成功/失败后如何写回 session”这一层编排逻辑。
*/
internal object BattleSessionRunAttemptResolver {
val directTrapConditions: Set<String> =
setOf(
"trapped",
"partially-trapped",
"cannot-escape",
)
fun resolve(
session: BattleSession,
action: BattleSessionRunAction,
): BattleSessionRunResolution {
val battle = session.currentSnapshot.battle
if (battle.battleKind != BattleType.WILD) {
return BattleSessionRunResolution(success = false, reason = "run-not-allowed")
}
val side =
session.currentSnapshot.sides[action.sideId]
?: return BattleSessionRunResolution(success = false, reason = "side-not-found")
val opponents =
session.currentSnapshot.sides.values
.filter { other -> other.id != action.sideId }
val runner = resolveRunnerUnit(session, side)
if (runner == null) {
return BattleSessionRunResolution(success = false, reason = "runner-not-found")
}
if (guaranteedRunSuccess(runner, session)) {
return BattleSessionRunResolution(
success = true,
runnerUnitId = runner.id,
reason = "guaranteed",
)
}
blockedRunReason(runner, opponents, session)
?.let { reason ->
return BattleSessionRunResolution(
success = false,
runnerUnitId = runner.id,
reason = reason,
failedAttempts = (battle.failedRunAttempts[action.sideId] ?: 0) + 1,
)
}
val failedAttempts = (battle.failedRunAttempts[action.sideId] ?: 0) + 1
val runnerSpeed = effectiveSpeed(runner).coerceAtLeast(1)
val opponentSpeed =
opponents
.flatMap(SideState::activeUnitIds)
.mapNotNull(session.currentSnapshot.units::get)
.maxOfOrNull(::effectiveSpeed)
?.coerceAtLeast(1)
?: 1
val scaledOpponentSpeed = ((opponentSpeed / 4) % 256).coerceAtLeast(1)
val escapeValue = floor((runnerSpeed * 32.0) / scaledOpponentSpeed.toDouble()).toInt() + (30 * failedAttempts)
if (escapeValue > 255) {
return BattleSessionRunResolution(
success = true,
runnerUnitId = runner.id,
reason = "formula",
failedAttempts = failedAttempts,
escapeValue = escapeValue,
)
}
val roll = session.nextRandomInt(256)
return if (roll < escapeValue) {
BattleSessionRunResolution(
success = true,
runnerUnitId = runner.id,
reason = "formula",
failedAttempts = failedAttempts,
escapeValue = escapeValue,
roll = roll,
)
} else {
BattleSessionRunResolution(
success = false,
runnerUnitId = runner.id,
reason = "formula",
failedAttempts = failedAttempts,
escapeValue = escapeValue,
roll = roll,
)
}
}
private fun resolveRunnerUnit(
session: BattleSession,
side: SideState,
): UnitState? =
side.activeUnitIds
.mapNotNull(session.currentSnapshot.units::get)
.maxByOrNull(::effectiveSpeed)
private fun guaranteedRunSuccess(
unit: UnitState,
session: BattleSession,
): Boolean =
"ghost" in unit.typeIds ||
unit.abilityId == "run-away" ||
BattleHeldItemRuntimeSupport.hasActiveItem(unit, session.currentSnapshot.field, BattleItemIds.SMOKE_BALL)
private fun blockedRunReason(
runner: UnitState,
opponents: List<SideState>,
session: BattleSession,
): String? {
if (directTrapConditions.any { it in runner.conditionStates || it in runner.volatileStates }) {
return "direct-trap"
}
val opponentUnits =
opponents
.flatMap(SideState::activeUnitIds)
.mapNotNull(session.currentSnapshot.units::get)
val shadowTagged =
opponentUnits.any { unit ->
unit.abilityId == "shadow-tag" &&
runner.abilityId != "shadow-tag"
}
if (shadowTagged) {
return "shadow-tag"
}
val arenaTrapped =
opponentUnits.any { unit -> unit.abilityId == "arena-trap" } &&
isGrounded(runner, session)
return if (arenaTrapped) {
"arena-trap"
} else {
null
}
}
private fun isGrounded(
unit: UnitState,
session: BattleSession,
): Boolean {
if (BattleFieldConditionSupport.hasGravity(session.currentSnapshot)) {
return true
}
if ("flying" in unit.typeIds) {
return false
}
if (unit.abilityId == "levitate") {
return false
}
if (BattleHeldItemRuntimeSupport.hasActiveItem(unit, session.currentSnapshot.field, BattleItemIds.AIR_BALLOON)) {
return false
}
if ("magnet-rise" in unit.volatileStates || "magnet-rise" in unit.conditionStates) {
return false
}
return true
}
private fun effectiveSpeed(unit: UnitState): Int {
val baseSpeed = BattleStatAliasResolver.readValue(unit.stats, BattleStatIds.SPEED) ?: 0
val stage = BattleStatStageSupport.readStage(unit.boosts, BattleStatIds.SPEED)
return (baseSpeed * BattleStatStageSupport.stageMultiplier(stage)).roundToInt()
}
}
/**
* 一次 session 逃跑尝试的解析结果。
*/
internal data class BattleSessionRunResolution(
val success: Boolean,
val reason: String,
val runnerUnitId: String? = null,
val failedAttempts: Int? = null,
val escapeValue: Int? = null,
val roll: Int? = null,
)