JsonEffectDefinitionBattleDataLoader.kt
package io.github.lishangbu.avalon.game.battle.engine.core.loader
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.ActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.ConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.EffectDefinition
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.HookRule
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.AddRelayActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.AddStatusActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.AddVolatileActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ApplyConditionActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.BoostActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.BoostFromRelayActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ChangeTypeActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ClearBoostsActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ClearProbeActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ClearTerrainActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ClearWeatherActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ConsumeItemActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.CopyBoostsActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.DamageActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.FailMoveActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ForceSwitchActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.InvertBoostRelayActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.InvertStoredBoostsActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.ModifyMultiplierActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.PrepareBoostPassActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.RemoveConditionActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.RemoveStatusActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.RemoveVolatileActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.RestorePpActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.SetProbeActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.SetRelayActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.SetTerrainActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.SetWeatherActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.StealBoostsActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.SwapBoostsActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.TriggerEventActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.AllConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.AnyConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.AttributeEqualsConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.BattleFormatIsConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.BoostCompareConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.ChanceConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasAbilityConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasItemConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasStatusConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasTypeConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasVolatileConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HpRatioConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.MoveHasTagConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.NotConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.StatCompareConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.TargetRelationConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.TerrainIsConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.TurnCompareConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.WeatherIsConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.type.ActionTypeId
import io.github.lishangbu.avalon.game.battle.engine.core.type.ActorId
import io.github.lishangbu.avalon.game.battle.engine.core.type.ConditionTypeId
import io.github.lishangbu.avalon.game.battle.engine.core.type.EffectKindId
import io.github.lishangbu.avalon.game.battle.engine.core.type.HookName
import io.github.lishangbu.avalon.game.battle.engine.core.type.SpecialHandlerId
import io.github.lishangbu.avalon.game.battle.engine.core.type.TargetSelectorId
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ObjectMapper
/**
* 从 JSON 资源加载 [EffectDefinition] 的基础实现。
*
* 设计意图:
* - 为第一版测试与样例数据提供真实文件加载入口。
* - 先支持当前已落地测试需要的 DSL 子集,再逐步扩展。
*
* 当前支持的条件类型:
* - `all`
* - `any`
* - `not`
* - `chance`
* - `hp_ratio`
* - `has_status`
* - `has_volatile`
* - `has_type`
* - `has_item`
* - `has_ability`
* - `weather_is`
* - `terrain_is`
* - `boost_compare`
* - `stat_compare`
* - `move_has_tag`
* - `target_relation`
* - `turn_compare`
* - `battle_format_is`
*
* 当前支持的动作类型:
* - `add_status`
* - `remove_status`
* - `add_volatile`
* - `remove_volatile`
* - `damage`
* - `heal`
* - `boost`
* - `clear_boosts`
* - `set_weather`
* - `clear_weather`
* - `set_terrain`
* - `clear_terrain`
* - `consume_item`
* - `restore_pp`
* - `change_type`
* - `force_switch`
* - `fail_move`
* - `trigger_event`
* - `apply_condition`
* - `remove_condition`
* - `modify_multiplier`
* - `add_relay`
* - `set_relay`
* - `invert_boost_relay`
* - `boost_from_relay`
* - `copy_boosts`
* - `swap_boosts`
* - `invert_stored_boosts`
* - `steal_boosts`
* - `prepare_boost_pass`
* - `set_probe`
* - `clear_probe`
*
* @property resourcePaths 要加载的 classpath 资源路径列表。
* @property objectMapper JSON 解析器。
*/
class JsonEffectDefinitionBattleDataLoader(
private val resourcePaths: List<String>,
private val objectMapper: ObjectMapper = ObjectMapper(),
) : BattleDataLoader {
override fun loadEffects(): List<EffectDefinition> = resourcePaths.map(::loadEffect)
private fun loadEffect(resourcePath: String): EffectDefinition {
val stream =
requireNotNull(javaClass.classLoader.getResourceAsStream(resourcePath)) {
"Resource '$resourcePath' was not found."
}
val rootNode = stream.use(objectMapper::readTree)
return parseEffect(rootNode)
}
private fun parseEffect(rootNode: JsonNode): EffectDefinition =
EffectDefinition(
id = rootNode.requiredText("id"),
kind = EffectKindId(rootNode.requiredText("kind")),
name = rootNode.requiredText("name"),
tags = rootNode.readStringSet("tags"),
data = rootNode.readObjectMap("data"),
hooks =
rootNode.path("hooks").properties().asSequence().associate { entry ->
HookName(entry.key) to entry.value.mapArray(::parseHookRule)
},
specialHandler = rootNode.optionalText("specialHandler")?.let(::SpecialHandlerId),
)
private fun parseHookRule(node: JsonNode): HookRule =
HookRule(
priority = node.path("priority").asInt(0),
subOrder = node.path("subOrder").asInt(0),
condition = node.get("if")?.let(::parseCondition),
thenActions = node.path("then").mapArray(::parseAction),
elseActions = node.path("else").mapArray(::parseAction),
tags = node.readStringSet("tags"),
)
private fun parseCondition(node: JsonNode): ConditionNode {
val typeId = ConditionTypeId(node.requiredText("type"))
return when (typeId.value) {
"all" -> {
AllConditionNode(
conditions = node.path("conditions").mapArray(::parseCondition),
)
}
"any" -> {
AnyConditionNode(
conditions = node.path("conditions").mapArray(::parseCondition),
)
}
"chance" -> {
ChanceConditionNode(
value = node.path("value").asInt(),
)
}
"not" -> {
NotConditionNode(
condition = parseCondition(node.required("condition")),
)
}
"hp_ratio" -> {
HpRatioConditionNode(
actor = ActorId(node.requiredText("actor")),
operator = node.requiredText("operator"),
value = node.path("value").asDouble(),
)
}
"has_status" -> {
HasStatusConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.optionalText("value"),
)
}
"has_volatile" -> {
HasVolatileConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.requiredText("value"),
)
}
"has_type" -> {
HasTypeConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.requiredText("value"),
)
}
"has_item" -> {
HasItemConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.requiredText("value"),
)
}
"has_ability" -> {
HasAbilityConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.requiredText("value"),
)
}
"weather_is" -> {
WeatherIsConditionNode(
value = node.requiredText("value"),
)
}
"terrain_is" -> {
TerrainIsConditionNode(
value = node.requiredText("value"),
)
}
"boost_compare" -> {
BoostCompareConditionNode(
actor = ActorId(node.requiredText("actor")),
stat = node.requiredText("stat"),
operator = node.requiredText("operator"),
value = node.path("value").asInt(),
)
}
"stat_compare" -> {
StatCompareConditionNode(
actor = ActorId(node.requiredText("actor")),
stat = node.requiredText("stat"),
operator = node.requiredText("operator"),
value = node.path("value").asInt(),
)
}
"move_has_tag" -> {
MoveHasTagConditionNode(
actor = ActorId(node.requiredText("actor")),
value = node.requiredText("value"),
)
}
"target_relation" -> {
TargetRelationConditionNode(
value = node.requiredText("value"),
)
}
"attribute_equals" -> {
AttributeEqualsConditionNode(
key = node.requiredText("key"),
value = node.requiredText("value"),
)
}
"turn_compare" -> {
TurnCompareConditionNode(
operator = node.requiredText("operator"),
value = node.path("value").asInt(),
)
}
"battle_format_is" -> {
BattleFormatIsConditionNode(
value = node.requiredText("value"),
)
}
else -> {
error("Unsupported condition type '${typeId.value}'.")
}
}
}
private fun parseAction(node: JsonNode): ActionNode {
val typeId = ActionTypeId(node.requiredText("type"))
return when (typeId.value) {
"add_status" -> {
AddStatusActionNode(
target = TargetSelectorId(node.requiredText("target")),
value = node.requiredText("value"),
duration = node.optionalInt("duration"),
)
}
"remove_status" -> {
RemoveStatusActionNode(
target = TargetSelectorId(node.requiredText("target")),
)
}
"add_volatile" -> {
AddVolatileActionNode(
target = TargetSelectorId(node.requiredText("target")),
value = node.requiredText("value"),
duration = node.optionalInt("duration"),
)
}
"remove_volatile" -> {
RemoveVolatileActionNode(
target = TargetSelectorId(node.requiredText("target")),
value = node.requiredText("value"),
)
}
"damage" -> {
DamageActionNode(
target = TargetSelectorId(node.requiredText("target")),
mode = node.optionalText("mode"),
value = node.path("value").asDouble(),
)
}
"heal" -> {
io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.HealActionNode(
target = TargetSelectorId(node.requiredText("target")),
mode = node.optionalText("mode"),
value = node.path("value").asDouble(),
)
}
"boost" -> {
BoostActionNode(
target = TargetSelectorId(node.requiredText("target")),
stats =
node.required("stats").properties().asSequence().associate { entry ->
entry.key to entry.value.asInt()
},
)
}
"clear_boosts" -> {
ClearBoostsActionNode(
target = TargetSelectorId(node.requiredText("target")),
)
}
"set_weather" -> {
SetWeatherActionNode(
value = node.requiredText("value"),
duration = node.optionalInt("duration"),
)
}
"clear_weather" -> {
ClearWeatherActionNode()
}
"set_terrain" -> {
SetTerrainActionNode(
value = node.requiredText("value"),
duration = node.optionalInt("duration"),
)
}
"clear_terrain" -> {
ClearTerrainActionNode()
}
"consume_item" -> {
ConsumeItemActionNode(
target = TargetSelectorId(node.requiredText("target")),
)
}
"restore_pp" -> {
RestorePpActionNode(
target = TargetSelectorId(node.requiredText("target")),
moveId = node.optionalText("moveId"),
value = node.path("value").asInt(),
)
}
"change_type" -> {
ChangeTypeActionNode(
target = TargetSelectorId(node.requiredText("target")),
values = node.path("values").mapArray { child -> child.asString() },
)
}
"force_switch" -> {
ForceSwitchActionNode(
target = TargetSelectorId(node.requiredText("target")),
)
}
"fail_move" -> {
FailMoveActionNode()
}
"trigger_event" -> {
TriggerEventActionNode(
hookName = HookName(node.requiredText("hookName")),
)
}
"apply_condition" -> {
ApplyConditionActionNode(
target = TargetSelectorId(node.requiredText("target")),
value = node.requiredText("value"),
duration = node.optionalInt("duration"),
)
}
"remove_condition" -> {
RemoveConditionActionNode(
target = TargetSelectorId(node.requiredText("target")),
value = node.requiredText("value"),
)
}
"modify_multiplier" -> {
ModifyMultiplierActionNode(
value = node.path("value").asDouble(),
rounding = node.optionalText("rounding"),
)
}
"add_relay" -> {
AddRelayActionNode(
value = node.path("value").asDouble(),
)
}
"set_relay" -> {
SetRelayActionNode(
value = readLiteral(node.required("value")),
)
}
"invert_boost_relay" -> {
InvertBoostRelayActionNode
}
"boost_from_relay" -> {
BoostFromRelayActionNode(
target = TargetSelectorId(node.requiredText("target")),
selection = node.optionalText("selection") ?: "all",
)
}
"copy_boosts" -> {
CopyBoostsActionNode(
target = TargetSelectorId(node.requiredText("target")),
from = TargetSelectorId(node.requiredText("from")),
selection = node.optionalText("selection") ?: "all",
stats = node.readStringSet("stats"),
)
}
"swap_boosts" -> {
SwapBoostsActionNode(
left = TargetSelectorId(node.requiredText("left")),
right = TargetSelectorId(node.requiredText("right")),
stats = node.readStringSet("stats"),
)
}
"invert_stored_boosts" -> {
InvertStoredBoostsActionNode(
target = TargetSelectorId(node.requiredText("target")),
stats = node.readStringSet("stats"),
)
}
"steal_boosts" -> {
StealBoostsActionNode(
target = TargetSelectorId(node.requiredText("target")),
from = TargetSelectorId(node.requiredText("from")),
selection = node.optionalText("selection") ?: "positive",
stats = node.readStringSet("stats"),
)
}
"prepare_boost_pass" -> {
PrepareBoostPassActionNode(
target = TargetSelectorId(node.requiredText("target")),
)
}
"set_probe" -> {
SetProbeActionNode(
target = TargetSelectorId(node.requiredText("target")),
key = node.requiredText("key"),
value = node.requiredText("value"),
)
}
"clear_probe" -> {
ClearProbeActionNode(
target = TargetSelectorId(node.requiredText("target")),
key = node.requiredText("key"),
)
}
else -> {
error("Unsupported action type '${typeId.value}'.")
}
}
}
private fun JsonNode.required(name: String): JsonNode =
requireNotNull(get(name)) {
"Field '$name' is required."
}
private fun JsonNode.requiredText(name: String): String = required(name).asString()
private fun JsonNode.optionalText(name: String): String? = get(name)?.asString()
private fun JsonNode.optionalInt(name: String): Int? = get(name)?.takeIf(JsonNode::isInt)?.asInt()
private fun JsonNode.readStringSet(name: String): Set<String> =
path(name)
.mapArray { it.asString() }
.toSet()
private fun JsonNode.readObjectMap(name: String): Map<String, Any?> =
path(name)
.properties()
.asSequence()
.associate { entry -> entry.key to readLiteral(entry.value) }
private fun readLiteral(node: JsonNode): Any? =
when {
node.isObject -> {
node
.properties()
.asSequence()
.associate { entry -> entry.key to readLiteral(entry.value) }
}
node.isArray -> {
node.mapArray(::readLiteral)
}
else -> {
readScalar(node)
}
}
private fun readScalar(node: JsonNode): Any? =
when {
node.isNull -> null
node.isInt -> node.asInt()
node.isLong -> node.asLong()
node.isDouble || node.isFloat || node.isBigDecimal -> node.asDouble()
node.isBoolean -> node.asBoolean()
else -> node.asString()
}
private fun <T> JsonNode.mapArray(transform: (JsonNode) -> T): List<T> = if (isArray) asSequence().map(transform).toList() else emptyList()
}