JsonEffectDefinitionSchemaValidator.kt
package io.github.lishangbu.avalon.game.battle.engine.core.loader
import io.github.lishangbu.avalon.game.battle.engine.core.event.StandardHookNames
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardActionTypeIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardActorIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardConditionTypeIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardEffectKindIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardTargetSelectorIds
import tools.jackson.databind.JsonNode
import tools.jackson.databind.ObjectMapper
/**
* 面向 EffectDefinition JSON 的最小 schema 校验器。
*
* 设计意图:
* - 在 loader 之前尽早发现结构问题。
* - 提供稳定、清晰的错误信息,而不是把格式错误泄漏到运行时解释阶段。
*
* 该实现不是完整 JSON Schema 引擎,而是当前 DSL 的结构校验器。
*/
class JsonEffectDefinitionSchemaValidator(
private val objectMapper: ObjectMapper = ObjectMapper(),
) : SchemaValidator {
override fun validate(rawDocument: String) {
val root = objectMapper.readTree(rawDocument)
validateTopLevel(root)
validateHooks(root.path("hooks"))
}
private fun validateTopLevel(root: JsonNode) {
require(root.isObject) { "Effect document must be a JSON object." }
require(root.hasNonNull("id")) { "Field 'id' is required." }
require(root.hasNonNull("kind")) { "Field 'kind' is required." }
require(root.hasNonNull("name")) { "Field 'name' is required." }
require(root.has("hooks")) { "Field 'hooks' is required." }
require(root.required("id").isString) { "Field 'id' must be a string." }
require(root.required("kind").isString) { "Field 'kind' must be a string." }
require(root.required("name").isString) { "Field 'name' must be a string." }
require(root.required("kind").asString() in supportedEffectKinds) {
"Field 'kind' has unsupported value '${root.required("kind").asString()}'."
}
require(root.path("hooks").isObject) { "Field 'hooks' must be an object." }
if (root.has("tags")) {
require(root.path("tags").isArray) { "Field 'tags' must be an array." }
root.path("tags").forEach { tagNode ->
require(tagNode.isString) { "Each tag must be a string." }
}
}
if (root.has("data")) {
require(root.path("data").isObject) { "Field 'data' must be an object." }
}
if (root.has("specialHandler")) {
require(root.path("specialHandler").isString) { "Field 'specialHandler' must be a string." }
}
}
private fun validateHooks(hooksNode: JsonNode) {
hooksNode.properties().forEach { entry ->
require(entry.key.isNotBlank()) { "Hook name must not be blank." }
require(entry.key in supportedHookNames) { "Hook '${entry.key}' is not supported." }
require(entry.value.isArray) { "Hook '${entry.key}' must be an array." }
entry.value.forEach { ruleNode -> validateRule(entry.key, ruleNode) }
}
}
private fun validateRule(
hookName: String,
ruleNode: JsonNode,
) {
require(ruleNode.isObject) { "Rule under hook '$hookName' must be an object." }
if (ruleNode.has("priority")) {
require(ruleNode.path("priority").isNumber) { "Field 'priority' under hook '$hookName' must be numeric." }
}
if (ruleNode.has("subOrder")) {
require(ruleNode.path("subOrder").isNumber) { "Field 'subOrder' under hook '$hookName' must be numeric." }
}
if (ruleNode.has("tags")) {
require(ruleNode.path("tags").isArray) { "Field 'tags' under hook '$hookName' must be an array." }
}
if (ruleNode.has("if")) {
validateCondition(ruleNode.required("if"))
}
validateActionArray(hookName, "then", ruleNode.path("then"))
validateActionArray(hookName, "else", ruleNode.path("else"))
}
private fun validateCondition(conditionNode: JsonNode) {
require(conditionNode.isObject) { "Condition node must be an object." }
val type = conditionNode.required("type").asString()
require(type in supportedConditionTypes) { "Unsupported condition type '$type'." }
when (type) {
"all", "any" -> {
val conditionsNode = conditionNode.path("conditions")
require(conditionsNode.isArray) { "Condition '$type' requires array field 'conditions'." }
require(!conditionsNode.isEmpty) { "Condition '$type' requires at least one child condition." }
conditionsNode.forEach(::validateCondition)
}
"not" -> {
validateCondition(conditionNode.required("condition"))
}
"chance" -> {
require(conditionNode.has("value")) { "Condition 'chance' requires field 'value'." }
require(conditionNode.path("value").isInt) { "Condition 'chance.value' must be an integer." }
val chance = conditionNode.path("value").asInt()
require(chance in 0..100) { "Condition 'chance.value' must be between 0 and 100." }
}
"hp_ratio" -> {
requireActorField(conditionNode, "actor", "Condition 'hp_ratio'")
requireTextField(conditionNode, "operator", "Condition 'hp_ratio'")
require(conditionNode.has("value")) { "Condition 'hp_ratio' requires field 'value'." }
require(conditionNode.path("value").isNumber) { "Condition 'hp_ratio.value' must be numeric." }
val ratio = conditionNode.path("value").asDouble()
require(ratio in 0.0..1.0) { "Condition 'hp_ratio.value' must be between 0.0 and 1.0." }
validateOperator(conditionNode.path("operator").asString(), "Condition 'hp_ratio'")
}
"has_status" -> {
requireActorField(conditionNode, "actor", "Condition 'has_status'")
if (conditionNode.has("value")) {
require(conditionNode.path("value").isString || conditionNode.path("value").isNull) {
"Condition 'has_status.value' must be a string or null."
}
}
}
"has_volatile", "has_type", "has_item", "has_ability", "move_has_tag" -> {
requireActorField(conditionNode, "actor", "Condition '$type'")
requireTextField(conditionNode, "value", "Condition '$type'")
}
"weather_is", "terrain_is", "target_relation", "battle_format_is" -> {
requireTextField(conditionNode, "value", "Condition '$type'")
}
"attribute_equals" -> {
requireTextField(conditionNode, "key", "Condition 'attribute_equals'")
requireTextField(conditionNode, "value", "Condition 'attribute_equals'")
}
"boost_compare", "stat_compare" -> {
requireActorField(conditionNode, "actor", "Condition '$type'")
requireTextField(conditionNode, "stat", "Condition '$type'")
requireTextField(conditionNode, "operator", "Condition '$type'")
require(conditionNode.has("value")) { "Condition '$type' requires field 'value'." }
require(conditionNode.path("value").isInt) { "Condition '$type.value' must be an integer." }
validateOperator(conditionNode.path("operator").asString(), "Condition '$type'")
}
"turn_compare" -> {
requireTextField(conditionNode, "operator", "Condition 'turn_compare'")
require(conditionNode.has("value")) { "Condition 'turn_compare' requires field 'value'." }
require(conditionNode.path("value").isInt) { "Condition 'turn_compare.value' must be an integer." }
validateOperator(conditionNode.path("operator").asString(), "Condition 'turn_compare'")
}
}
}
private fun validateActionArray(
hookName: String,
fieldName: String,
actionArrayNode: JsonNode,
) {
if (!actionArrayNode.isArray) {
require(actionArrayNode.isMissingNode) { "Field '$fieldName' under hook '$hookName' must be an array." }
return
}
actionArrayNode.forEach { actionNode -> validateAction(hookName, fieldName, actionNode) }
}
private fun validateAction(
hookName: String,
fieldName: String,
actionNode: JsonNode,
) {
require(actionNode.isObject) { "Action under '$hookName.$fieldName' must be an object." }
val type = actionNode.required("type").asString()
require(type in supportedActionTypes) { "Unsupported action type '$type'." }
when (type) {
"add_status", "add_volatile", "apply_condition", "remove_condition" -> {
requireTargetField(actionNode, "target", "Action '$type'")
requireTextField(actionNode, "value", "Action '$type'")
if (type in setOf("add_status", "add_volatile", "apply_condition") && actionNode.has("duration")) {
require(actionNode.path("duration").isInt) { "Action '$type.duration' must be an integer." }
require(actionNode.path("duration").asInt() > 0) { "Action '$type.duration' must be greater than 0." }
}
}
"remove_status", "clear_boosts", "consume_item", "force_switch" -> {
requireTargetField(actionNode, "target", "Action '$type'")
}
"remove_volatile" -> {
requireTargetField(actionNode, "target", "Action 'remove_volatile'")
requireTextField(actionNode, "value", "Action 'remove_volatile'")
}
"damage", "heal" -> {
requireTargetField(actionNode, "target", "Action '$type'")
require(actionNode.has("value")) { "Action '$type' requires field 'value'." }
require(actionNode.path("value").isNumber) { "Action '$type.value' must be numeric." }
if (actionNode.has("mode")) {
require(actionNode.path("mode").isString) { "Action '$type.mode' must be a string." }
}
}
"boost" -> {
requireTargetField(actionNode, "target", "Action 'boost'")
require(actionNode.path("stats").isObject) { "Action 'boost.stats' must be an object." }
require(!actionNode.path("stats").isEmpty) { "Action 'boost.stats' must not be empty." }
actionNode.path("stats").properties().forEach { entry ->
require(entry.value.isInt) { "Action 'boost.stats.${entry.key}' must be an integer." }
}
}
"set_weather", "set_terrain" -> {
requireTextField(actionNode, "value", "Action '$type'")
if (actionNode.has("duration")) {
require(actionNode.path("duration").isInt) { "Action '$type.duration' must be an integer." }
require(actionNode.path("duration").asInt() > 0) { "Action '$type.duration' must be greater than 0." }
}
}
"restore_pp" -> {
requireTargetField(actionNode, "target", "Action 'restore_pp'")
require(actionNode.has("value")) { "Action 'restore_pp' requires field 'value'." }
require(actionNode.path("value").isInt) { "Action 'restore_pp.value' must be an integer." }
if (actionNode.has("moveId")) {
require(actionNode.path("moveId").isString) { "Action 'restore_pp.moveId' must be a string." }
}
}
"change_type" -> {
requireTargetField(actionNode, "target", "Action 'change_type'")
require(actionNode.path("values").isArray) { "Action 'change_type.values' must be an array." }
require(!actionNode.path("values").isEmpty) { "Action 'change_type.values' must not be empty." }
actionNode.path("values").forEach { valueNode ->
require(valueNode.isString) { "Each 'change_type.values' item must be a string." }
}
}
"trigger_event" -> {
requireHookField(actionNode, "hookName", "Action 'trigger_event'")
}
"modify_multiplier" -> {
require(actionNode.has("value")) { "Action 'modify_multiplier' requires field 'value'." }
require(actionNode.path("value").isNumber) { "Action 'modify_multiplier.value' must be numeric." }
if (actionNode.has("rounding")) {
require(actionNode.path("rounding").isString) { "Action 'modify_multiplier.rounding' must be a string." }
}
}
"add_relay" -> {
require(actionNode.has("value")) { "Action 'add_relay' requires field 'value'." }
require(actionNode.path("value").isNumber) { "Action 'add_relay.value' must be numeric." }
}
"set_relay" -> {
require(actionNode.has("value")) { "Action 'set_relay' requires field 'value'." }
}
"invert_boost_relay" -> {
}
"boost_from_relay" -> {
requireTargetField(actionNode, "target", "Action 'boost_from_relay'")
if (actionNode.has("selection")) {
require(actionNode.path("selection").isString) { "Action 'boost_from_relay.selection' must be a string." }
require(actionNode.path("selection").asString() in setOf("all", "positive", "negative")) {
"Action 'boost_from_relay.selection' must be one of all/positive/negative."
}
}
}
"copy_boosts", "steal_boosts" -> {
requireTargetField(actionNode, "target", "Action '$type'")
requireTargetField(actionNode, "from", "Action '$type'")
if (actionNode.has("selection")) {
require(actionNode.path("selection").isString) { "Action '$type.selection' must be a string." }
require(actionNode.path("selection").asString() in setOf("all", "positive", "negative")) {
"Action '$type.selection' must be one of all/positive/negative."
}
}
if (actionNode.has("stats")) {
requireStringArrayField(actionNode, "stats", "Action '$type'")
}
}
"swap_boosts" -> {
requireTargetField(actionNode, "left", "Action 'swap_boosts'")
requireTargetField(actionNode, "right", "Action 'swap_boosts'")
if (actionNode.has("stats")) {
requireStringArrayField(actionNode, "stats", "Action 'swap_boosts'")
}
}
"invert_stored_boosts" -> {
requireTargetField(actionNode, "target", "Action 'invert_stored_boosts'")
if (actionNode.has("stats")) {
requireStringArrayField(actionNode, "stats", "Action 'invert_stored_boosts'")
}
}
"prepare_boost_pass" -> {
requireTargetField(actionNode, "target", "Action 'prepare_boost_pass'")
}
"set_probe" -> {
requireTargetField(actionNode, "target", "Action 'set_probe'")
requireTextField(actionNode, "key", "Action 'set_probe'")
requireTextField(actionNode, "value", "Action 'set_probe'")
}
"clear_probe" -> {
requireTargetField(actionNode, "target", "Action 'clear_probe'")
requireTextField(actionNode, "key", "Action 'clear_probe'")
}
}
}
private fun JsonNode.required(name: String): JsonNode =
requireNotNull(get(name)) {
"Field '$name' is required."
}
private fun requireTextField(
node: JsonNode,
fieldName: String,
owner: String,
) {
require(node.hasNonNull(fieldName)) { "$owner requires field '$fieldName'." }
require(node.path(fieldName).isString) { "$owner field '$fieldName' must be a string." }
}
private fun requireActorField(
node: JsonNode,
fieldName: String,
owner: String,
) {
requireTextField(node, fieldName, owner)
val actor = node.path(fieldName).asString()
require(actor in supportedActorIds) { "$owner field '$fieldName' has unsupported actor '$actor'." }
}
private fun requireTargetField(
node: JsonNode,
fieldName: String,
owner: String,
) {
requireTextField(node, fieldName, owner)
val target = node.path(fieldName).asString()
require(target in supportedTargetSelectors) {
"$owner field '$fieldName' has unsupported target selector '$target'."
}
}
private fun requireHookField(
node: JsonNode,
fieldName: String,
owner: String,
) {
requireTextField(node, fieldName, owner)
val hookName = node.path(fieldName).asString()
require(hookName in supportedHookNames) { "$owner field '$fieldName' has unsupported hook '$hookName'." }
}
private fun requireStringArrayField(
node: JsonNode,
fieldName: String,
owner: String,
) {
require(node.path(fieldName).isArray) { "$owner field '$fieldName' must be an array." }
node.path(fieldName).forEach { item ->
require(item.isString) { "$owner field '$fieldName' must contain only strings." }
}
}
private fun validateOperator(
operator: String,
owner: String,
) {
require(operator in supportedOperators) { "$owner operator '$operator' is not supported." }
}
private companion object {
private val supportedConditionTypes =
setOf(
StandardConditionTypeIds.ALL.value,
StandardConditionTypeIds.ANY.value,
StandardConditionTypeIds.NOT.value,
StandardConditionTypeIds.CHANCE.value,
StandardConditionTypeIds.HP_RATIO.value,
StandardConditionTypeIds.HAS_STATUS.value,
StandardConditionTypeIds.HAS_VOLATILE.value,
StandardConditionTypeIds.HAS_TYPE.value,
StandardConditionTypeIds.HAS_ITEM.value,
StandardConditionTypeIds.HAS_ABILITY.value,
StandardConditionTypeIds.WEATHER_IS.value,
StandardConditionTypeIds.TERRAIN_IS.value,
StandardConditionTypeIds.BOOST_COMPARE.value,
StandardConditionTypeIds.STAT_COMPARE.value,
StandardConditionTypeIds.MOVE_HAS_TAG.value,
StandardConditionTypeIds.TARGET_RELATION.value,
StandardConditionTypeIds.TURN_COMPARE.value,
StandardConditionTypeIds.ATTRIBUTE_EQUALS.value,
StandardConditionTypeIds.BATTLE_FORMAT_IS.value,
)
private val supportedActionTypes =
setOf(
StandardActionTypeIds.DAMAGE.value,
StandardActionTypeIds.HEAL.value,
StandardActionTypeIds.ADD_STATUS.value,
StandardActionTypeIds.REMOVE_STATUS.value,
StandardActionTypeIds.ADD_VOLATILE.value,
StandardActionTypeIds.REMOVE_VOLATILE.value,
StandardActionTypeIds.BOOST.value,
StandardActionTypeIds.CLEAR_BOOSTS.value,
StandardActionTypeIds.SET_WEATHER.value,
StandardActionTypeIds.CLEAR_WEATHER.value,
StandardActionTypeIds.SET_TERRAIN.value,
StandardActionTypeIds.CLEAR_TERRAIN.value,
StandardActionTypeIds.CONSUME_ITEM.value,
StandardActionTypeIds.RESTORE_PP.value,
StandardActionTypeIds.CHANGE_TYPE.value,
StandardActionTypeIds.FORCE_SWITCH.value,
StandardActionTypeIds.FAIL_MOVE.value,
StandardActionTypeIds.TRIGGER_EVENT.value,
StandardActionTypeIds.APPLY_CONDITION.value,
StandardActionTypeIds.REMOVE_CONDITION.value,
StandardActionTypeIds.MODIFY_MULTIPLIER.value,
StandardActionTypeIds.ADD_RELAY.value,
StandardActionTypeIds.SET_RELAY.value,
StandardActionTypeIds.INVERT_BOOST_RELAY.value,
StandardActionTypeIds.BOOST_FROM_RELAY.value,
StandardActionTypeIds.SET_PROBE.value,
StandardActionTypeIds.CLEAR_PROBE.value,
)
private val supportedEffectKinds =
setOf(
StandardEffectKindIds.MOVE.value,
StandardEffectKindIds.ABILITY.value,
StandardEffectKindIds.ITEM.value,
StandardEffectKindIds.STATUS.value,
StandardEffectKindIds.VOLATILE.value,
StandardEffectKindIds.SIDE_CONDITION.value,
StandardEffectKindIds.PSEUDO_WEATHER.value,
StandardEffectKindIds.WEATHER.value,
StandardEffectKindIds.TERRAIN.value,
StandardEffectKindIds.FORMAT_RULE.value,
)
private val supportedHookNames =
setOf(
StandardHookNames.ON_SWITCH_IN.value,
StandardHookNames.ON_SWITCH_OUT.value,
StandardHookNames.ON_BEFORE_TURN.value,
StandardHookNames.ON_BEFORE_MOVE.value,
StandardHookNames.ON_TRY_MOVE.value,
StandardHookNames.ON_PREPARE_HIT.value,
StandardHookNames.ON_TRY_HIT.value,
StandardHookNames.ON_MODIFY_ACCURACY.value,
StandardHookNames.ON_MODIFY_EVASION.value,
StandardHookNames.ON_MODIFY_BASE_POWER.value,
StandardHookNames.ON_MODIFY_ATTACK.value,
StandardHookNames.ON_MODIFY_DEFENSE.value,
StandardHookNames.ON_MODIFY_CRIT_RATIO.value,
StandardHookNames.ON_MODIFY_STAB.value,
StandardHookNames.ON_MODIFY_DAMAGE.value,
StandardHookNames.ON_BEFORE_DAMAGE.value,
StandardHookNames.ON_DAMAGE.value,
StandardHookNames.ON_HEAL.value,
StandardHookNames.ON_HIT.value,
StandardHookNames.ON_AFTER_HIT.value,
StandardHookNames.ON_AFTER_MOVE.value,
StandardHookNames.ON_SET_STATUS.value,
StandardHookNames.ON_APPLY_CONDITION.value,
StandardHookNames.ON_REMOVE_STATUS.value,
StandardHookNames.ON_TRY_ADD_VOLATILE.value,
StandardHookNames.ON_REMOVE_VOLATILE.value,
StandardHookNames.ON_BOOST.value,
StandardHookNames.ON_CLEAR_BOOSTS.value,
StandardHookNames.ON_REMOVE_CONDITION.value,
StandardHookNames.ON_CONSUME_ITEM.value,
StandardHookNames.ON_RESTORE_PP.value,
StandardHookNames.ON_CHANGE_TYPE.value,
StandardHookNames.ON_RESIDUAL.value,
StandardHookNames.ON_WEATHER_CHANGE.value,
StandardHookNames.ON_TERRAIN_CHANGE.value,
StandardHookNames.ON_FAINT.value,
)
private val supportedActorIds =
setOf(
StandardActorIds.SELF.value,
StandardActorIds.TARGET.value,
StandardActorIds.SOURCE.value,
StandardActorIds.MOVE.value,
StandardActorIds.FIELD.value,
StandardActorIds.SIDE.value,
StandardActorIds.FOE_SIDE.value,
)
private val supportedTargetSelectors =
setOf(
StandardTargetSelectorIds.SELF.value,
StandardTargetSelectorIds.TARGET.value,
StandardTargetSelectorIds.SOURCE.value,
StandardTargetSelectorIds.ALLY.value,
StandardTargetSelectorIds.ALL_ALLIES.value,
StandardTargetSelectorIds.FOE.value,
StandardTargetSelectorIds.ALL_FOES.value,
StandardTargetSelectorIds.SIDE.value,
StandardTargetSelectorIds.FOE_SIDE.value,
StandardTargetSelectorIds.FIELD.value,
StandardTargetSelectorIds.ALL.value,
)
private val supportedOperators =
setOf(
">",
">=",
"<",
"<=",
"==",
"!=",
)
}
}