DefaultBattleEngineService.kt
package io.github.lishangbu.avalon.game.battle.engine.application
import io.github.lishangbu.avalon.game.battle.engine.application.gateway.BattleSessionGateway
import io.github.lishangbu.avalon.game.battle.engine.core.model.BattleLifecycle
import io.github.lishangbu.avalon.game.battle.engine.core.model.BattleType
import io.github.lishangbu.avalon.game.battle.engine.core.session.BattleSessionQuery
import io.github.lishangbu.avalon.game.battle.engine.core.session.BattleSessionTurnResult
import io.github.lishangbu.avalon.game.battle.engine.core.session.CaptureChoice
import io.github.lishangbu.avalon.game.battle.engine.core.session.RunChoice
import io.github.lishangbu.avalon.game.battle.engine.core.session.SwitchChoice
import io.github.lishangbu.avalon.game.battle.engine.core.session.target.BattleSessionTargetQuery
import io.github.lishangbu.avalon.game.battle.engine.spi.ai.BattleAiChoiceProvider
/**
* battle-engine 默认应用门面。
*
* 设计意图:
* - 对外提供稳定的程序化入口,把建局、智能 choice 解析、回合推进与结算计划生成统一收口;
* - 内部仍然复用 `BattleSessionGateway` 这类更细粒度的基础设施,不把底层细节暴露给 game;
* - 为 controller、game.init、game.settlement 提供明确的调用边界。
*/
class DefaultBattleEngineService(
private val battleSessionGateway: BattleSessionGateway,
private val battleChoiceResolver: BattleChoiceResolver,
private val battleAiChoiceProviders: List<BattleAiChoiceProvider>,
private val battleSettlementPlanners: List<BattleSettlementPlanner>,
) : BattleEngineService {
override fun initializeSession(command: InitializeBattleCommand): BattleSessionQuery {
require(command.sides.isNotEmpty()) { "At least one side must be provided." }
battleSessionGateway.createSession(command.sessionId, command.formatId)
battleSessionGateway.configureSession(
sessionId = command.sessionId,
battleKind = command.battleKind,
capturableSideId = command.capturableSideId,
)
command.sides.forEach { side ->
battleSessionGateway.registerSide(command.sessionId, side.sideId)
val normalizedActiveIds = normalizeActiveUnitIds(side)
side.units.forEach { unit ->
battleSessionGateway.registerUnit(
sessionId = command.sessionId,
sideId = side.sideId,
unit = unit,
active = unit.id in normalizedActiveIds,
)
}
}
return if (command.autoStart) {
battleSessionGateway.startSession(command.sessionId)
} else {
battleSessionGateway.querySession(command.sessionId)
}
}
override fun startSession(sessionId: String): BattleSessionQuery = battleSessionGateway.startSession(sessionId)
override fun querySession(sessionId: String): BattleSessionQuery = battleSessionGateway.querySession(sessionId)
override fun queryTargets(
sessionId: String,
effectId: String,
actorUnitId: String,
): BattleSessionTargetQuery =
battleChoiceResolver.queryTargets(
session = battleSessionGateway.querySession(sessionId),
effectId = effectId,
actorUnitId = actorUnitId,
)
override fun submitMoveChoice(
sessionId: String,
command: SubmitMoveChoiceCommand,
): BattleSessionQuery {
val session = battleSessionGateway.querySession(sessionId)
val choice = battleChoiceResolver.createMoveChoice(session, command)
return battleSessionGateway.submitChoice(sessionId, choice)
}
override fun submitItemChoice(
sessionId: String,
command: SubmitItemChoiceCommand,
): BattleSessionQuery {
val session = battleSessionGateway.querySession(sessionId)
val choice = battleChoiceResolver.createItemChoice(session, command)
return battleSessionGateway.submitChoice(sessionId, choice)
}
override fun submitCaptureChoice(
sessionId: String,
command: SubmitCaptureChoiceCommand,
): BattleSessionQuery {
val session = battleSessionGateway.querySession(sessionId)
val resolvedSourceUnitId = resolveCaptureSourceUnitId(session, command.sourceUnitId, command.targetId)
val speed =
command.speed ?: (
session.snapshot.units[resolvedSourceUnitId]
?.stats
?.get("speed") ?: 0
)
return battleSessionGateway.submitChoice(
sessionId,
CaptureChoice(
playerId = command.playerId,
ballItemId = command.ballItemId,
sourceUnitId = resolvedSourceUnitId,
targetId = command.targetId,
priority = command.priority ?: 0,
speed = speed,
),
)
}
override fun submitSwitchChoice(
sessionId: String,
command: SubmitSwitchChoiceCommand,
): BattleSessionQuery =
battleSessionGateway.submitChoice(
sessionId,
SwitchChoice(
sideId = command.sideId,
outgoingUnitId = command.outgoingUnitId,
incomingUnitId = command.incomingUnitId,
priority = command.priority ?: 0,
speed = command.speed ?: 0,
),
)
override fun submitRunChoice(
sessionId: String,
command: SubmitRunChoiceCommand,
): BattleSessionQuery =
battleSessionGateway.submitChoice(
sessionId,
RunChoice(
sideId = command.sideId,
priority = command.priority ?: 0,
speed = command.speed ?: resolveRunSpeed(battleSessionGateway.querySession(sessionId), command.sideId),
),
)
override fun submitReplacementChoice(
sessionId: String,
command: SubmitReplacementChoiceCommand,
): BattleSessionQuery = battleSessionGateway.submitReplacementChoice(sessionId, command.sideId, command.incomingUnitId)
override fun resolveTurn(sessionId: String): BattleSessionTurnResult {
val session = battleSessionGateway.querySession(sessionId)
resolveAiChoiceProvider(session.snapshot.battle.battleKind)
.provide(session)
.forEach { choice ->
battleSessionGateway.submitChoice(sessionId = sessionId, choice = choice)
}
return battleSessionGateway.resolveTurn(sessionId)
}
override fun buildSettlementPlan(sessionId: String): BattleSettlementPlan {
val session = battleSessionGateway.querySession(sessionId)
val lifecycle = session.snapshot.battle.lifecycle
require(lifecycle == BattleLifecycle.ENDED_UNSETTLED || lifecycle == BattleLifecycle.SETTLED) {
"Battle '$sessionId' must be in lifecycle '${BattleLifecycle.ENDED_UNSETTLED.name}' " +
"or '${BattleLifecycle.SETTLED.name}', but was '${lifecycle.name}'."
}
return resolveSettlementPlanner(session.snapshot.battle.battleKind).buildPlan(sessionId, session)
}
override fun markSessionSettled(sessionId: String): BattleSessionQuery = battleSessionGateway.markSessionSettled(sessionId)
private fun normalizeActiveUnitIds(side: InitializeBattleSideCommand): Set<String> {
require(side.units.isNotEmpty()) { "Side '${side.sideId}' must contain at least one unit." }
val allUnitIds = side.units.map { unit -> unit.id }.toSet()
val normalized = side.activeUnitIds.ifEmpty { setOf(side.units.first().id) }
require(normalized.all { unitId -> unitId in allUnitIds }) {
"Side '${side.sideId}' has activeUnitIds that are not present in its unit list."
}
return normalized
}
private fun resolveCaptureSourceUnitId(
session: BattleSessionQuery,
requestedSourceUnitId: String?,
targetId: String,
): String {
if (!requestedSourceUnitId.isNullOrBlank()) {
return requestedSourceUnitId
}
val capturableSideId = session.snapshot.battle.capturableSideId
return session.snapshot.sides.values
.firstOrNull { side -> side.id != capturableSideId && side.activeUnitIds.isNotEmpty() }
?.activeUnitIds
?.firstOrNull()
?: error("No active source unit is available for capture target '$targetId'.")
}
private fun resolveRunSpeed(
session: BattleSessionQuery,
sideId: String,
): Int =
session.snapshot.sides[sideId]
?.activeUnitIds
?.mapNotNull(session.snapshot.units::get)
?.maxOfOrNull { unit -> unit.stats["speed"] ?: unit.stats["spe"] ?: 0 }
?: 0
private fun resolveAiChoiceProvider(battleKind: BattleType): BattleAiChoiceProvider =
battleAiChoiceProviders.firstOrNull { provider -> provider.supports(battleKind) }
?: error("No BattleAiChoiceProvider registered for battleKind '$battleKind'.")
private fun resolveSettlementPlanner(battleKind: BattleType): BattleSettlementPlanner =
battleSettlementPlanners.firstOrNull { planner -> planner.supports(battleKind) }
?: error("No BattleSettlementPlanner registered for battleKind '$battleKind'.")
}