SmartBattleEffectAssembler.kt
package io.github.lishangbu.avalon.game.service.effect
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.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.ConsumeItemActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.action.HealActionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.AllConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.ChanceConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HasItemConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.HpRatioConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.dsl.condition.TargetRelationConditionNode
import io.github.lishangbu.avalon.game.battle.engine.core.event.StandardHookNames
import io.github.lishangbu.avalon.game.battle.engine.core.type.ActorId
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardEffectKindIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.StandardTargetSelectorIds
import io.github.lishangbu.avalon.game.battle.engine.core.type.TargetSelectorId
internal data class MoveImportRecord(
val internalName: String,
val name: String,
val typeInternalName: String?,
val damageClassInternalName: String?,
val targetInternalName: String?,
val accuracy: Int?,
val effectChance: Int?,
val pp: Int?,
val priority: Int?,
val power: Int?,
val shortEffect: String?,
val effect: String?,
val ailmentInternalName: String?,
val ailmentChance: Int?,
val healing: Int?,
val drain: Int?,
)
internal data class AbilityImportRecord(
val internalName: String,
val name: String,
val effect: String?,
val introduction: String?,
)
internal data class ItemImportRecord(
val internalName: String,
val name: String,
val shortEffect: String?,
val effect: String?,
val text: String?,
val attributeInternalNames: Set<String>,
val flingEffectInternalName: String?,
)
internal object SmartBattleEffectAssembler {
fun fromMove(record: MoveImportRecord): EffectDefinition {
val hooks = linkedMapOf<io.github.lishangbu.avalon.game.battle.engine.core.type.HookName, MutableList<HookRule>>()
val secondaryEffectAction = moveSecondaryEffectAction(record)
val secondaryEffectChance = record.ailmentChance ?: record.effectChance
if (record.healing != null && record.healing > 0) {
hooks.getOrPut(StandardHookNames.ON_HIT, ::mutableListOf) +=
HookRule(
thenActions =
listOf(
HealActionNode(
target = StandardTargetSelectorIds.SELF,
mode = "max_hp_ratio",
value = record.healing / 100.0,
),
),
)
}
if (secondaryEffectAction != null) {
hooks.getOrPut(StandardHookNames.ON_HIT, ::mutableListOf) +=
HookRule(
condition = chanceCondition(secondaryEffectChance, guaranteedSecondaryEffect(record)),
thenActions = listOf(secondaryEffectAction),
)
}
return EffectDefinition(
id = record.internalName,
kind = StandardEffectKindIds.MOVE,
name = record.name,
tags =
buildSet {
add(if ((record.power ?: 0) > 0) "damaging" else "status")
record.damageClassInternalName?.let(::add)
record.typeInternalName?.let(::add)
if ((record.healing ?: 0) > 0) {
add("recovery")
}
normalizedAilmentTag(record.ailmentInternalName)?.let(::add)
},
data =
buildMap {
put("source", "dataset")
put("entity", "move")
put("target", record.targetInternalName)
put("type", record.typeInternalName)
put("damageClass", record.damageClassInternalName)
put("accuracy", record.accuracy)
put("effectChance", record.effectChance)
put("pp", record.pp)
put("priority", record.priority)
put("basePower", record.power)
put("shortEffect", record.shortEffect)
put("effect", record.effect)
put("healing", record.healing)
put("drain", record.drain)
}.filterValues { it != null },
hooks = hooks.mapValues { (_, rules) -> rules.toList() },
)
}
fun fromAbility(record: AbilityImportRecord): EffectDefinition {
val hooks =
when (record.internalName) {
"static" -> {
mapOf(
StandardHookNames.ON_HIT to
listOf(
HookRule(
condition =
AllConditionNode(
conditions =
listOf(
ChanceConditionNode(30),
TargetRelationConditionNode("foe"),
),
),
thenActions =
listOf(
AddStatusActionNode(
target = StandardTargetSelectorIds.SOURCE,
value = "par",
),
),
),
),
)
}
"speed-boost" -> {
mapOf(
StandardHookNames.ON_RESIDUAL to
listOf(
HookRule(
thenActions =
listOf(
BoostActionNode(
target = StandardTargetSelectorIds.SELF,
stats = mapOf("speed" to 1),
),
),
),
),
)
}
else -> {
emptyMap()
}
}
return EffectDefinition(
id = record.internalName,
kind = StandardEffectKindIds.ABILITY,
name = record.name,
tags = inferTextTags(record.internalName, record.effect, record.introduction),
data =
buildMap {
put("source", "dataset")
put("entity", "ability")
put("effect", record.effect)
put("introduction", record.introduction)
}.filterValues { it != null },
hooks = hooks,
)
}
fun fromItem(record: ItemImportRecord): EffectDefinition {
val hooks =
healingItemHook(record)
?.let { rule ->
mapOf(StandardHookNames.ON_RESIDUAL to listOf(rule))
}.orEmpty()
return EffectDefinition(
id = record.internalName,
kind = StandardEffectKindIds.ITEM,
name = record.name,
tags =
buildSet {
addAll(record.attributeInternalNames)
addAll(inferTextTags(record.internalName, record.shortEffect, record.effect, record.text))
},
data =
buildMap {
put("source", "dataset")
put("entity", "item")
put("shortEffect", record.shortEffect)
put("effect", record.effect)
put("text", record.text)
put("flingEffect", record.flingEffectInternalName)
}.filterValues { it != null },
hooks = hooks,
)
}
private fun healingItemHook(record: ItemImportRecord): HookRule? {
val recovery =
when (record.internalName) {
"sitrus-berry" -> HealingSpec(mode = "max_hp_ratio", value = 0.25)
"oran-berry" -> HealingSpec(mode = null, value = 10.0)
else -> null
} ?: return null
return HookRule(
condition =
AllConditionNode(
conditions =
listOf(
HasItemConditionNode(
actor = ActorId("self"),
value = record.internalName,
),
HpRatioConditionNode(
actor = ActorId("self"),
operator = "<=",
value = 0.5,
),
),
),
thenActions =
listOf(
ConsumeItemActionNode(StandardTargetSelectorIds.SELF),
HealActionNode(
target = StandardTargetSelectorIds.SELF,
mode = recovery.mode,
value = recovery.value,
),
),
)
}
private fun moveSecondaryEffectAction(record: MoveImportRecord): ActionNode? {
val ailment = normalizedAilmentTag(record.ailmentInternalName) ?: return null
val target = targetSelectorOf(record.targetInternalName)
return when (ailment) {
"paralysis" -> AddStatusActionNode(target, "par")
"burn" -> AddStatusActionNode(target, "brn")
"poison" -> AddStatusActionNode(target, "psn")
"sleep" -> AddStatusActionNode(target, "slp")
"freeze" -> AddStatusActionNode(target, "frz")
"confusion" -> AddVolatileActionNode(target, "confusion")
else -> ApplyConditionActionNode(target, ailment)
}
}
private fun targetSelectorOf(rawTarget: String?): TargetSelectorId =
when (rawTarget) {
"self", "user" -> StandardTargetSelectorIds.SELF
"ally" -> StandardTargetSelectorIds.ALLY
"users-field" -> StandardTargetSelectorIds.SIDE
"user-or-ally", "user-and-allies", "all-allies" -> StandardTargetSelectorIds.ALL_ALLIES
"opponents-field" -> StandardTargetSelectorIds.FOE_SIDE
"all-opponents" -> StandardTargetSelectorIds.ALL_FOES
"all-other-pokemon", "all-pokemon", "entire-field" -> StandardTargetSelectorIds.ALL
else -> StandardTargetSelectorIds.TARGET
}
private fun guaranteedSecondaryEffect(record: MoveImportRecord): Boolean =
record.ailmentInternalName != null &&
((record.power ?: 0) <= 0 || record.damageClassInternalName == "status") &&
(record.ailmentChance == null && record.effectChance == null)
private fun chanceCondition(
chance: Int?,
guaranteed: Boolean,
): ConditionNode? {
if (guaranteed) {
return null
}
if (chance == null || chance <= 0 || chance >= 100) {
return null
}
return ChanceConditionNode(chance)
}
private fun normalizedAilmentTag(ailmentInternalName: String?): String? =
when (ailmentInternalName) {
null, "", "unknown", "none" -> null
else -> ailmentInternalName
}
private fun inferTextTags(vararg values: String?): Set<String> {
val text = values.filterNotNull().joinToString(" ").lowercase()
return buildSet {
if ("berry" in text) {
add("berry")
}
if ("recover" in text || "restore" in text || "heal" in text) {
add("recovery")
}
if ("contact" in text) {
add("contact_reactive")
}
if ("paraly" in text) {
add("paralysis")
}
if ("burn" in text) {
add("burn")
}
if ("confus" in text) {
add("confusion")
}
}
}
private data class HealingSpec(
val mode: String?,
val value: Double,
)
}