Compare commits

...
1304 changed files with 22012 additions and 48 deletions
@@ -18,6 +18,7 @@ object CobblemonCommands {
fun register(dispatcher: CommandDispatcher<CommandSourceStack>, registry: CommandBuildContext, selection: Commands.CommandSelection) {
SpawnPokemon.register(dispatcher)
SpawnNPCCommand.register(dispatcher)
NPCBattleDebugCommand.register(dispatcher)
GivePokemon.register(dispatcher)
TakePokemon.register(dispatcher)
ChangeEyeHeight.register(dispatcher)
@@ -166,6 +166,10 @@ open class PokemonBattle(
return otherSide.actors.all { it.type == ActorType.NPC }
}
/** Whether this battle is NPC-vs-NPC. */
val isNvN: Boolean
get() = actors.any() && actors.all { it.type == ActorType.NPC }
/**
* Gets an actor by their showdown id
* @return the actor if found otherwise null
@@ -505,6 +509,24 @@ open class PokemonBattle(
fun checkForInputDispatch() {
if (checkForfeit()) return // ignore actors that are still choosing, their choices don't matter anymore
if (isNvN) {
val actorsWithPendingChoices = actors.filter { actor ->
val request = actor.request ?: return@filter false
if (request.wait) return@filter false
(request.active?.isNotEmpty() == true) || request.forceSwitch.any { it }
}
val readyToInputNvN = actorsWithPendingChoices.isNotEmpty() &&
actorsWithPendingChoices.all { !it.mustChoose && it.responses.isNotEmpty() }
if (readyToInputNvN && captureActions.isEmpty()) {
actorsWithPendingChoices.forEach { it.writeShowdownResponse() }
actorsWithPendingChoices.forEach {
it.responses.clear()
it.request = null
}
}
return
}
val readyToInput = (actors.any { !it.mustChoose && it.responses.isNotEmpty() } && actors.none { it.mustChoose })
if (readyToInput && captureActions.isEmpty()) {
actors.filter { it.responses.isNotEmpty() }.forEach { it.writeShowdownResponse() }
@@ -560,4 +582,4 @@ open class PokemonBattle(
queryStruct.addFunction("has_rule") { params -> DoubleValue(params.getString(0) in format.ruleSet) }
return queryStruct
}
}
}
@@ -10,15 +10,19 @@ package com.cobblemon.mod.common.api.battles.model.actor
import com.cobblemon.mod.common.Cobblemon.LOGGER
import com.cobblemon.mod.common.api.battles.model.ai.BattleAI
import com.cobblemon.mod.common.battles.DefaultActionResponse
import com.cobblemon.mod.common.api.net.NetworkPacket
import com.cobblemon.mod.common.battles.PassActionResponse
import com.cobblemon.mod.common.battles.ShowdownActionRequest
import com.cobblemon.mod.common.battles.ShowdownActionResponse
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.exception.IllegalActionChoiceException
import com.cobblemon.mod.common.net.messages.client.battle.BattleFaintPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleHealthChangePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleMadeInvalidChoicePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleMakeChoicePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattlePersistentStatusPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleQueueRequestPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleReplacePokemonPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleSwapPokemonPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleSwitchPokemonPacket
@@ -30,11 +34,16 @@ abstract class AIBattleActor(
pokemonList: List<BattlePokemon>,
val battleAI: BattleAI
) : BattleActor(gameId, pokemonList.toMutableList()) {
private var lastChoiceRequest: ShowdownActionRequest? = null
private var invalidChoiceRetries = 0
override fun sendUpdate(packet: NetworkPacket<*>) {
super.sendUpdate(packet)
when (packet) {
is BattleMakeChoicePacket -> this.onChoiceRequested()
is BattleQueueRequestPacket -> this.onQueueRequest(packet.request)
is BattleMadeInvalidChoicePacket -> this.onInvalidChoiceRequested()
is BattlePersistentStatusPacket -> {}
is BattleFaintPacket -> {}
is BattleSwitchPokemonPacket -> if (!packet.isAlly) { /* TODO this might be redundant; switch on opposing site should be visible during turn */ }
@@ -49,30 +58,31 @@ abstract class AIBattleActor(
* Called when the AI is requested to make a choice.
*/
open fun onChoiceRequested() {
refreshChoiceRetryState()
try {
request?.let {
setActionResponses(it.iterate(this.activePokemon) { battleMon, moveset, forceSwitch ->
val request = request
if (request != null) {
setActionResponses(request.iterate(this.activePokemon) { battleMon, moveset, forceSwitch ->
battleAI.choose(battleMon, battle, getSide(), moveset, forceSwitch)
})
pokemonList.forEach { pokemon ->
pokemon.willBeSwitchedIn = false
}
} ?: {
val response = mutableListOf<ShowdownActionResponse>()
repeat(activePokemon.size) {
response.add(PassActionResponse)
}
setActionResponses(response)
LOGGER.warn("AI requested choice, but no request was set. Returning PassActionResponses.")
invalidChoiceRetries = 0
} else {
// A stale or out-of-order choice prompt should never block battle flow.
mustChoose = false
LOGGER.warn("AI requested choice, but no request was set. Ignoring stale choice prompt.")
}
} catch (exception: IllegalActionChoiceException) {
LOGGER.error("AI was unable to choose an action, we're going to need to pass!")
exception.printStackTrace()
request?.let {
setActionResponses(it.iterate(this.activePokemon) { _, _ , _->
val request = request
if (request != null) {
setActionResponses(request.iterate(this.activePokemon) { _, _ , _ ->
PassActionResponse
})
} ?: {
} else {
val response = mutableListOf<ShowdownActionResponse>()
repeat(activePokemon.size) {
response.add(PassActionResponse)
@@ -81,4 +91,47 @@ abstract class AIBattleActor(
}
}
}
}
private fun onInvalidChoiceRequested() {
refreshChoiceRetryState()
val request = request
if (request == null) {
mustChoose = false
return
}
invalidChoiceRetries++
if (invalidChoiceRetries <= 2) {
onChoiceRequested()
return
}
LOGGER.warn("AI produced repeated invalid choices. Falling back to default action.")
setActionResponses(
request.iterate(this.activePokemon) { _, _, _ ->
DefaultActionResponse()
}
)
invalidChoiceRetries = 0
}
private fun onQueueRequest(request: ShowdownActionRequest) {
val hasActionableChoice = !request.wait && ((request.active?.isNotEmpty() == true) || request.forceSwitch.any { it })
if (!hasActionableChoice) {
return
}
// If turn prompt is missed due message ordering, AI can still proceed from the queued request.
if (battle.turn > 0 || request.forceSwitch.any { it }) {
mustChoose = true
onChoiceRequested()
}
}
private fun refreshChoiceRetryState() {
if (request !== lastChoiceRequest) {
lastChoiceRequest = request
invalidChoiceRetries = 0
}
}
}
@@ -76,18 +76,17 @@ abstract class BattleActor(
fun turn() {
val request = request ?: return
responses.clear()
if(activePokemon.any { it.isAlive() }) {
mustChoose = true
sendUpdate(BattleMakeChoicePacket())
}
val requestActive = request.active
if (requestActive == null || requestActive.isEmpty() || request.wait) {
this.request = null
expectingPassActions.clear()
return
}
if (activePokemon.any { it.isAlive() }) {
mustChoose = true
sendUpdate(BattleMakeChoicePacket())
}
}
fun upkeep() {
@@ -159,4 +158,4 @@ abstract class BattleActor(
open fun win(otherWinners: List<BattleActor>, losers: List<BattleActor>) {}
open fun lose(winners: List<BattleActor>, otherLosers: List<BattleActor>) {}
}
}
@@ -67,6 +67,8 @@ object CobblemonPermissions {
val SPAWN_POKEMON = this.create("${COMMAND_PREFIX}spawnpokemon", PermissionLevel.CHEAT_COMMANDS_AND_COMMAND_BLOCKS)
@JvmStatic
val SPAWN_NPC = this.create("${COMMAND_PREFIX}spawnnpc", PermissionLevel.CHEAT_COMMANDS_AND_COMMAND_BLOCKS)
@JvmStatic
val NPC_BATTLE_DEBUG = this.create("${COMMAND_PREFIX}npcbattledebug", PermissionLevel.CHEAT_COMMANDS_AND_COMMAND_BLOCKS)
@JvmStatic
@@ -161,4 +163,4 @@ object CobblemonPermissions {
return permission
}
}
}
@@ -172,9 +172,11 @@ object BattleRegistry {
messages.add(""">player ${actor.showdownId} {"name":"${actor.uuid}","team":"${actor.pokemonList.packTeam()}"}""")
}
// -> Set team size
for (actor in battle.actors.sortedBy { it.showdownId }) {
messages.add(">${actor.showdownId} team ${actor.pokemonList.count()}")
// -> Set team size only when Team Preview is active.
if (BattleRules.TEAM_PREVIEW in battle.format.ruleSet) {
for (actor in battle.actors.sortedBy { it.showdownId }) {
messages.add(">${actor.showdownId} team ${actor.pokemonList.count()}")
}
}
// Compiles the request and sends it off
@@ -242,4 +244,4 @@ object BattleRegistry {
fun tick() {
battleMap.forEachValue(Long.MAX_VALUE) { it.tick() }
}
}
}
@@ -0,0 +1,573 @@
package com.cobblemon.mod.common.battles.ai
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.battles.interpreter.BattleContext
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.battles.model.ai.BattleAI
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.battles.*
import com.cobblemon.mod.common.battles.ai.competitive.*
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.net.messages.client.battle.BattleHealthChangePacket
import kotlin.random.Random
class CompetitiveBattleAI(private val skill: Int) : BattleAI {
companion object {
private const val DEBUG_TOP_MOVES = 4
}
private val skillLevel = skill.coerceIn(0, 5)
private val aiDebugEnabled = isAiDebugEnabled()
private val battleTracker = EnhancedActiveTracker()
private val opponentPredictor = OpponentPredictor()
private val strategyAnalyzer = StrategyAnalyzer()
private lateinit var damageCalculator: DamageCalculator
private lateinit var moveEvaluator: MoveEvaluator
private lateinit var switchDecisionMaker: SwitchDecisionMaker
private lateinit var doublesDecisionMaker: DoublesDecisionMaker
private var currentBattle: PokemonBattle? = null
private var currentGamePlan: GamePlan? = null
private var battleState: BattleState? = null
private var lastMoveEvaluation: MoveEvaluator.MoveEvaluation? = null
private var currentAiSide: BattleSide? = null
private var currentOpponentSide: BattleSide? = null
private var isDoublesMode: Boolean = false
private var currentSlot: Int = 0
private var currentTurn: Int = -1
private val turnDecisions: MutableMap<Int, DoublesDecisionMaker.MoveTargetPair> = mutableMapOf()
private var firstSlotDecided: Boolean = false
override fun choose(
activeBattlePokemon: ActiveBattlePokemon,
battle: PokemonBattle,
aiSide: BattleSide,
moveset: ShowdownMoveset?,
forceSwitch: Boolean
): ShowdownActionResponse {
try {
currentAiSide = aiSide
currentOpponentSide = aiSide.getOppositeSide()
initializeComponents(battle)
updateBattleState(activeBattlePokemon, battle, aiSide)
if (currentGamePlan == null) {
val npcTeam = aiSide.actors.first().pokemonList.map { it.effectedPokemon }
currentGamePlan = strategyAnalyzer.analyzeTeamComposition(npcTeam)
}
if (battle.turn != currentTurn) {
currentTurn = battle.turn
turnDecisions.clear()
firstSlotDecided = false
}
if (forceSwitch || activeBattlePokemon.isGone()) {
return handleForcedSwitch(activeBattlePokemon, battle)
}
if (!passesSkillCheck()) {
debugLog("Skill check failed; selecting random move.")
return makeRandomMove(moveset, activeBattlePokemon)
}
updateOpponentModel()
if (isDoublesMode) {
return chooseForDoubles(activeBattlePokemon, battle, moveset)
}
if (shouldAttemptSwitch() && shouldSwitch(activeBattlePokemon, battle)) {
val switchTarget = findBestSwitch(activeBattlePokemon, battle)
if (switchTarget != null) {
switchTarget.willBeSwitchedIn = true
logSwitchDecision("voluntary_switch", availableSwitches = getAvailableSwitches(activeBattlePokemon), chosen = switchTarget)
return SwitchActionResponse(switchTarget.uuid)
}
}
val bestMove = chooseBestMove(moveset, activeBattlePokemon, battle)
logMoveDecision("move_select", bestMove, activeBattlePokemon)
return executeMove(bestMove, activeBattlePokemon)
} catch (e: Exception) {
e.printStackTrace()
return PassActionResponse
}
}
private fun chooseForDoubles(
activeBattlePokemon: ActiveBattlePokemon,
battle: PokemonBattle,
moveset: ShowdownMoveset?
): ShowdownActionResponse {
val state = battleState ?: return PassActionResponse
val plan = currentGamePlan ?: return PassActionResponse
val myPokemon = battleTracker.p2Active.getActiveAt(currentSlot) ?: return PassActionResponse
val partner = battleTracker.p2Active.getActiveAt(1 - currentSlot)
val opponents = battleTracker.p1Active.getAllActive()
val decision = doublesDecisionMaker.findBestMoveAndTarget(
moveset = moveset,
activeBattlePokemon = activeBattlePokemon,
myPokemon = myPokemon,
opponents = opponents,
partner = partner,
battleState = state,
gamePlan = plan
) ?: return makeRandomMove(moveset, activeBattlePokemon)
turnDecisions[currentSlot] = decision
firstSlotDecided = true
debugLog(
"Doubles: move=${decision.move.id} target=${decision.targetPNX ?: "auto"} " +
"score=${"%.2f".format(decision.evaluation.totalScore)}"
)
return if (decision.targetPNX != null) {
MoveActionResponse(decision.move.id, decision.targetPNX)
} else {
MoveActionResponse(decision.move.id)
}
}
private fun initializeComponents(battle: PokemonBattle) {
if (currentBattle != battle) {
currentBattle = battle
isDoublesMode = battle.format.battleType.pokemonPerSide > 1
damageCalculator = DamageCalculator(battle)
moveEvaluator = MoveEvaluator(damageCalculator, opponentPredictor, isDoublesMode)
switchDecisionMaker = SwitchDecisionMaker(damageCalculator, opponentPredictor, strategyAnalyzer)
doublesDecisionMaker = DoublesDecisionMaker(moveEvaluator, damageCalculator)
}
}
private fun updateBattleState(activeBattlePokemon: ActiveBattlePokemon, battle: PokemonBattle, aiSide: BattleSide) {
val pnx = activeBattlePokemon.getPNX()
currentSlot = if (pnx.length >= 3) (pnx[2] - 'a').coerceAtLeast(0) else 0
val opponentSide = aiSide.getOppositeSide()
if (isDoublesMode) {
opponentSide.activePokemon.forEachIndexed { index, active ->
active.battlePokemon?.let { battleTracker.updatePokemonSlot(battleTracker.p1Active, index, it) }
}
aiSide.activePokemon.forEachIndexed { index, active ->
active.battlePokemon?.let { battleTracker.updatePokemonSlot(battleTracker.p2Active, index, it) }
}
battleTracker.p1Active.activePokemon =
battleTracker.p1Active.getActiveAt(currentSlot) ?: battleTracker.p1Active.activePokemon
battleTracker.p2Active.activePokemon =
battleTracker.p2Active.getActiveAt(currentSlot) ?: battleTracker.p2Active.activePokemon
} else {
opponentSide.activePokemon.firstOrNull()?.battlePokemon?.let { playerPokemon ->
battleTracker.updatePokemon(battleTracker.p1Active.activePokemon, playerPokemon)
battleTracker.updateParty(battleTracker.p1Active, battleTracker.p1Active.activePokemon)
}
activeBattlePokemon.battlePokemon?.let { aiPokemon ->
battleTracker.updatePokemon(battleTracker.p2Active.activePokemon, aiPokemon)
battleTracker.updateParty(battleTracker.p2Active, battleTracker.p2Active.activePokemon)
}
}
updateRevealedMoves(battle)
battleState = createBattleState(battle, aiSide)
}
private fun createBattleState(battle: PokemonBattle, aiSide: BattleSide): BattleState? {
val opponentSide = aiSide.getOppositeSide()
val playerActor = opponentSide.actors.firstOrNull() ?: return null
val aiActor = aiSide.actors.firstOrNull() ?: return null
val playerActiveTracker = if (isDoublesMode) battleTracker.p1Active.getActiveAt(currentSlot) ?: battleTracker.p1Active.activePokemon else battleTracker.p1Active.activePokemon
val npcActiveTracker = if (isDoublesMode) battleTracker.p2Active.getActiveAt(currentSlot) ?: battleTracker.p2Active.activePokemon else battleTracker.p2Active.activePokemon
val playerActive = playerActiveTracker.pokemon ?: return null
val npcActive = npcActiveTracker.pokemon ?: return null
val playerActiveSlots = battleTracker.p1Active.getAllActive().mapNotNull { it.pokemon }.ifEmpty { listOf(playerActive) }
val npcActiveSlots = battleTracker.p2Active.getAllActive().mapNotNull { it.pokemon }.ifEmpty { listOf(npcActive) }
val partnerSlot = if (isDoublesMode) 1 - currentSlot else null
return BattleState(
turn = battle.turn,
playerTeam = playerActor.pokemonList.map { it.effectedPokemon },
npcTeam = aiActor.pokemonList.map { it.effectedPokemon },
playerActive = playerActive,
npcActive = npcActive,
weather = getWeather(battle),
terrain = getTerrain(battle),
playerHazards = getHazards(opponentSide),
npcHazards = getHazards(aiSide),
playerBoosts = battleTracker.p1Active.activePokemon.boosts,
npcBoosts = battleTracker.p2Active.activePokemon.boosts,
battleContext = battle.contextManager,
isDoubles = isDoublesMode,
playerActiveSlots = playerActiveSlots,
npcActiveSlots = npcActiveSlots,
mySlotIndex = currentSlot,
partnerSlotIndex = partnerSlot
)
}
private fun handleForcedSwitch(activeBattlePokemon: ActiveBattlePokemon, battle: PokemonBattle): ShowdownActionResponse {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
if (availableSwitches.isEmpty()) {
return ForfeitActionResponse()
}
val state = battleState
val plan = currentGamePlan
val bestSwitch = if (state != null && plan != null) {
switchDecisionMaker.findBestSwitch(
availableSwitches = availableSwitches,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
} else {
null
}
val switchTarget = bestSwitch ?: availableSwitches.first()
switchTarget.willBeSwitchedIn = true
logSwitchDecision("forced_switch", availableSwitches, switchTarget)
return SwitchActionResponse(switchTarget.uuid)
}
private fun shouldSwitch(activeBattlePokemon: ActiveBattlePokemon, battle: PokemonBattle): Boolean {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
if (availableSwitches.isEmpty()) return false
val state = battleState ?: return false
val plan = currentGamePlan ?: return false
return switchDecisionMaker.shouldVoluntarilySwitch(
myPokemon = battleTracker.p2Active.activePokemon,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan,
availableSwitches = availableSwitches
)
}
private fun findBestSwitch(activeBattlePokemon: ActiveBattlePokemon, battle: PokemonBattle): BattlePokemon? {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
val state = battleState ?: return null
val plan = currentGamePlan ?: return null
return switchDecisionMaker.findBestSwitch(
availableSwitches = availableSwitches,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
}
private fun chooseBestMove(
moveset: ShowdownMoveset?,
activeBattlePokemon: ActiveBattlePokemon,
battle: PokemonBattle
): InBattleMove? {
if (moveset == null) return null
val usableMoves = moveset.moves.filter { it.canBeUsed() }
if (usableMoves.isEmpty()) return null
val state = battleState ?: return null
val plan = currentGamePlan ?: return null
val evaluations = usableMoves.map { move ->
moveEvaluator.evaluateMove(
move = move,
attacker = battleTracker.p2Active.activePokemon,
defender = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
}
val bestEvaluation = evaluations.maxByOrNull { it.totalScore }
logMoveRanking(evaluations)
lastMoveEvaluation = bestEvaluation
return bestEvaluation?.move
}
private fun executeMove(move: InBattleMove?, activeBattlePokemon: ActiveBattlePokemon): ShowdownActionResponse {
if (move == null) return makeRandomMove(null, activeBattlePokemon)
val target = if (move.mustBeUsed()) null else move.target.targetList(activeBattlePokemon)
return if (target == null) {
MoveActionResponse(move.id)
} else {
val chosenTarget = target.filter { !it.isAllied(activeBattlePokemon) }.randomOrNull() ?: target.random()
MoveActionResponse(move.id, (chosenTarget as ActiveBattlePokemon).getPNX())
}
}
private fun makeRandomMove(moveset: ShowdownMoveset?, activeBattlePokemon: ActiveBattlePokemon): ShowdownActionResponse {
if (moveset == null) return PassActionResponse
val usableMoves = moveset.moves.filter { it.canBeUsed() }
val move = usableMoves.randomOrNull() ?: return PassActionResponse
val target = if (move.mustBeUsed()) null else move.target.targetList(activeBattlePokemon)
return if (target == null) {
MoveActionResponse(move.id)
} else {
val chosenTarget = target.filter { !it.isAllied(activeBattlePokemon) }.randomOrNull() ?: target.random()
MoveActionResponse(move.id, (chosenTarget as ActiveBattlePokemon).getPNX())
}
}
private fun updateOpponentModel() {
val situation = categorizeSituation(battleState ?: return)
val opponentActives = if (isDoublesMode) battleTracker.p1Active.getAllActive() else listOf(battleTracker.p1Active.activePokemon)
for (opponentActive in opponentActives) {
for (move in opponentActive.revealedMoves) {
opponentPredictor.updateMoveHistory(
pokemon = opponentActive.species ?: "unknown",
move = move,
situation = situation.name.lowercase()
)
}
}
}
override fun onHealthChange(packet: BattleHealthChangePacket) {
try {
val battle = currentBattle ?: return
val activePokemon = battle.activePokemon.find { it.getPNX() == packet.pnx } ?: return
val newHealth = packet.newHealth.toInt()
val isOpponentPokemon = currentOpponentSide?.actors?.contains(activePokemon.actor) == true
val teamTracker = if (isOpponentPokemon) battleTracker.p1Active else battleTracker.p2Active
val tracker = battleTracker.getTrackedPokemonByPNX(packet.pnx, teamTracker) ?: teamTracker.activePokemon
val oldHealth = tracker.currentHp
val hpChange = newHealth - oldHealth
tracker.previousHp = oldHealth
tracker.currentHp = newHealth
tracker.lastHpChange = hpChange
tracker.currentHpPercent = if (tracker.maxHp > 0) newHealth.toDouble() / tracker.maxHp else 0.0
updateMomentum(hpChange, isOpponentPokemon)
detectCriticalThresholds(tracker, oldHealth, newHealth, isOpponentPokemon)
} catch (_: Exception) {
}
}
private fun updateMomentum(hpChange: Int, isOpponentPokemon: Boolean) {
val momentumChange = when {
hpChange > 0 -> -5.0
hpChange < 0 -> kotlin.math.abs(hpChange) / 10.0
else -> 0.0
}
if (isOpponentPokemon) {
battleTracker.p2Active.momentum += momentumChange
battleTracker.p1Active.momentum -= momentumChange
} else {
battleTracker.p2Active.momentum -= momentumChange
battleTracker.p1Active.momentum += momentumChange
}
battleTracker.p1Active.momentum = battleTracker.p1Active.momentum.coerceIn(-100.0, 100.0)
battleTracker.p2Active.momentum = battleTracker.p2Active.momentum.coerceIn(-100.0, 100.0)
}
private fun detectCriticalThresholds(
tracker: TrackedPokemon,
oldHealth: Int,
newHealth: Int,
isOpponentPokemon: Boolean
) {
if (newHealth <= 0 && oldHealth > 0) {
if (isOpponentPokemon) {
battleTracker.p2Active.momentum += 20.0
} else {
battleTracker.p1Active.momentum += 20.0
}
}
}
private fun categorizeSituation(battleState: BattleState): BattleSituation {
val myHP = battleState.npcActive.currentHealth.toDouble() / battleState.npcActive.maxHealth
val oppHP = battleState.playerActive.currentHealth.toDouble() / battleState.playerActive.maxHealth
return when {
myHP < 0.25 -> BattleSituation.DESPERATE
oppHP < 0.25 -> BattleSituation.WINNING
myHP > 0.8 && oppHP > 0.8 -> BattleSituation.EARLY_GAME
battleState.npcTeam.count { it.currentHealth > 0 } <= 2 -> BattleSituation.ENDGAME
battleState.playerBoosts.values.any { it >= 2 } -> BattleSituation.OPPONENT_SETUP
battleState.npcBoosts.values.any { it >= 2 } -> BattleSituation.MY_SETUP
battleState.weather != null -> BattleSituation.WEATHER_CONTROL
else -> BattleSituation.NEUTRAL
}
}
private fun getAvailableSwitches(activeBattlePokemon: ActiveBattlePokemon): List<BattlePokemon> {
val currentUuid = activeBattlePokemon.battlePokemon?.uuid
return activeBattlePokemon.actor.pokemonList.filter {
it.health > 0 &&
it.uuid != currentUuid &&
try {
it.canBeSentOut()
} catch (_: Exception) {
true
}
}
}
private fun passesSkillCheck(): Boolean {
if (skillLevel == 5) return true
if (skillLevel == 0) return false
val baseChance = skillLevel * 20
val turnBonus = if ((battleState?.turn ?: 0) > 10) 10 else 0
return Random.nextInt(100) < (baseChance + turnBonus)
}
private fun shouldAttemptSwitch(): Boolean {
return when (skillLevel) {
in 0..2 -> false
3 -> Random.nextDouble() < 0.25
4 -> Random.nextDouble() < 0.70
5 -> true
else -> false
}
}
private fun getWeather(battle: PokemonBattle): String? {
val weather = battle.contextManager.get(BattleContext.Type.WEATHER)?.firstOrNull()?.id
return AiBattleUtils.normalizeWeatherId(weather)
}
private fun getTerrain(battle: PokemonBattle): String? {
return battle.contextManager.get(BattleContext.Type.TERRAIN)?.firstOrNull()?.id
}
private fun getHazards(side: BattleSide): List<String> {
return side.contextManager.get(BattleContext.Type.HAZARD)?.map { it.id } ?: emptyList()
}
private fun updateRevealedMoves(battle: PokemonBattle) {
updateTeamRevealedMoves(battle, battleTracker.p1Active)
updateTeamRevealedMoves(battle, battleTracker.p2Active)
}
private fun updateTeamRevealedMoves(battle: PokemonBattle, team: TeamTracker) {
val active = team.getAllActive().ifEmpty { listOf(team.activePokemon) }
for (tracked in active) {
val pokemon = tracked.pokemon ?: continue
val majorMessage = battle.majorBattleActions[pokemon.uuid]
val minorMessage = battle.minorBattleActions[pokemon.uuid]
val candidates = listOfNotNull(majorMessage, minorMessage)
for (message in candidates) {
val id = message.id.lowercase()
if (id != "move" && id != "-singlemove" && id != "singlemove") continue
val moveId = message.effectAt(1)?.id?.lowercase() ?: continue
tracked.revealedMoves.add(moveId)
}
}
}
private fun logMoveDecision(
reason: String,
move: InBattleMove?,
activeBattlePokemon: ActiveBattlePokemon
) {
if (!aiDebugEnabled || move == null) return
val eval = lastMoveEvaluation
val self = activeBattlePokemon.battlePokemon?.effectedPokemon?.species?.name ?: "unknown"
val opp = battleTracker.p1Active.activePokemon.species ?: "unknown"
val plan = currentGamePlan?.winCondition?.name ?: "unknown"
if (eval == null) {
debugLog("Decision=$reason self=$self opp=$opp move=${move.id} plan=$plan")
return
}
debugLog(
"Decision=$reason self=$self opp=$opp move=${move.id} plan=$plan " +
"score=${"%.2f".format(eval.totalScore)} " +
"expectedDamage=${"%.1f".format(eval.expectedDamage)} " +
"strategic=${"%.1f".format(eval.strategicValue)} " +
"risk=${"%.2f".format(eval.riskFactor)}"
)
}
private fun logMoveRanking(evaluations: List<MoveEvaluator.MoveEvaluation>) {
if (!aiDebugEnabled || evaluations.isEmpty()) return
val plan = currentGamePlan?.winCondition?.name ?: "unknown"
val situation = battleState?.let { categorizeSituation(it).name } ?: "unknown"
val top = evaluations.sortedByDescending { it.totalScore }.take(DEBUG_TOP_MOVES)
val state = battleState
val planData = currentGamePlan
val attacker = battleTracker.p2Active.activePokemon
val defender = battleTracker.p1Active.activePokemon
val details = top.joinToString(", ") { eval ->
val debug = if (state != null && planData != null) {
moveEvaluator.buildDebugDetails(eval.move, attacker, defender, state, planData)
} else {
null
}
val flags = if (debug != null) {
listOf(
"stab=${debug.hasStab}",
"eff=${"%.2f".format(debug.effectiveness)}",
"acc=${"%.2f".format(debug.accuracy)}",
"prio=${debug.priority}",
"status=${debug.isStatusMove}",
"setup=${debug.isSetup}",
"hazardSet=${debug.setsHazards}",
"hazardClear=${debug.removesHazards}",
"weather=${debug.setsWeather}",
"recovery=${debug.isRecovery}",
"pivot=${debug.isPivot}",
"risky=${debug.isRisky}"
).joinToString(" ")
} else {
"n/a"
}
"${eval.move.id}:" +
"score=${"%.2f".format(eval.totalScore)} " +
"raw=${"%.1f".format(eval.rawDamage)} " +
"dmg=${"%.1f".format(eval.expectedDamage)} " +
"strat=${"%.1f".format(eval.strategicValue)} " +
"align=${"%.1f".format(eval.gamePlanAlignment)} " +
"turn=${"%.1f".format(eval.turnAdvantage)} " +
"risk=${"%.2f".format(eval.riskFactor)} " +
flags
}
debugLog("MoveRank plan=$plan situation=$situation top=[$details]")
}
private fun logSwitchDecision(
reason: String,
availableSwitches: List<BattlePokemon>,
chosen: BattlePokemon?
) {
if (!aiDebugEnabled) return
val state = battleState
val plan = currentGamePlan
val opponent = battleTracker.p1Active.activePokemon
val ranked = if (state != null && plan != null) {
availableSwitches.map { candidate ->
val score = switchDecisionMaker.evaluateSwitchOption(
candidate = candidate.effectedPokemon,
opponent = opponent,
battleState = state,
gamePlan = plan
)
candidate to score
}.sortedByDescending { it.second }
.take(3)
.joinToString(", ") { (candidate, score) ->
"${candidate.effectedPokemon.species.name}:${"%.1f".format(score)}"
}
} else {
"n/a"
}
val chosenName = chosen?.effectedPokemon?.species?.name ?: "none"
debugLog("Decision=$reason chosen=$chosenName options=[$ranked]")
}
private fun debugLog(message: String) {
if (aiDebugEnabled) {
Cobblemon.LOGGER.info("[CompetitiveBattleAI] $message")
}
}
private fun isAiDebugEnabled(): Boolean {
return System.getProperty("cobblemon.ai.debug", "false").toBooleanStrictOrNull() ?: false
}
}
@@ -0,0 +1,656 @@
package com.cobblemon.mod.common.battles.ai
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.battles.interpreter.BattleContext
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.battles.model.actor.ActorType
import com.cobblemon.mod.common.api.battles.model.ai.BattleAI
import com.cobblemon.mod.common.battles.*
import com.cobblemon.mod.common.battles.ai.competitive.*
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.net.messages.client.battle.BattleHealthChangePacket
import java.util.UUID
import kotlin.random.Random
class NewCompetitiveBattleAI(private val skill: Int) : BattleAI {
companion object {
private const val DEBUG_TOP_MOVES = 4
}
private val skillLevel = skill.coerceIn(0, 5)
private val aiDebugEnabled = isAiDebugEnabled()
private val battleTracker = NewEnhancedActiveTracker()
private val opponentPredictor = NewOpponentPredictor()
private val strategyAnalyzer = NewStrategyAnalyzer()
private lateinit var damageCalculator: NewDamageCalculator
private lateinit var moveEvaluator: NewMoveEvaluator
private lateinit var switchDecisionMaker: NewSwitchDecisionMaker
private lateinit var doublesDecisionMaker: NewDoublesDecisionMaker
private var currentBattle: PokemonBattle? = null
private var currentGamePlan: GamePlan? = null
private var battleState: BattleState? = null
private var lastMoveEvaluation: NewMoveEvaluator.MoveEvaluation? = null
private var currentAiSide: BattleSide? = null
private var currentOpponentSide: BattleSide? = null
private var isDoublesMode: Boolean = false
private var currentSlot: Int = 0
private var currentTurn: Int = -1
private val turnDecisions: MutableMap<Int, NewDoublesDecisionMaker.MoveTargetPair> = mutableMapOf()
private var firstSlotDecided: Boolean = false
private val lastVoluntarySwitchTurnByActor: MutableMap<UUID, Int> = mutableMapOf()
private val lastVoluntarySwitchFromByActor: MutableMap<UUID, String> = mutableMapOf()
private val lastVoluntarySwitchToByActor: MutableMap<UUID, String> = mutableMapOf()
override fun choose(
activeBattlePokemon: ActiveBattlePokemon,
battle: PokemonBattle,
aiSide: BattleSide,
moveset: ShowdownMoveset?,
forceSwitch: Boolean
): ShowdownActionResponse {
try {
currentAiSide = aiSide
currentOpponentSide = aiSide.getOppositeSide()
initializeComponents(battle)
updateBattleState(activeBattlePokemon, battle, aiSide)
if (currentGamePlan == null) {
val npcTeam = aiSide.actors.first().pokemonList.map { it.effectedPokemon }
currentGamePlan = strategyAnalyzer.analyzeTeamComposition(npcTeam)
}
if (battle.turn != currentTurn) {
currentTurn = battle.turn
turnDecisions.clear()
firstSlotDecided = false
}
logDecisionContext(activeBattlePokemon, forceSwitch)
if (forceSwitch || activeBattlePokemon.isGone()) {
return handleForcedSwitch(activeBattlePokemon)
}
if (!passesSkillCheck()) {
debugLog("Skill check failed; selecting random move.")
return makeRandomMove(moveset, activeBattlePokemon)
}
updateOpponentModel()
if (isDoublesMode) {
return chooseForDoubles(activeBattlePokemon, moveset)
}
val switchGateOpen = shouldAttemptSwitch()
if (switchGateOpen) {
val shouldVoluntarySwitch = shouldSwitch(activeBattlePokemon)
debugLog("Switch Gate: open | evaluator=$shouldVoluntarySwitch | ${switchDecisionMaker.latestSwitchRationale()}")
if (shouldVoluntarySwitch) {
val diagnostics = switchDecisionMaker.latestDiagnosticsSnapshot()
val actorId = activeBattlePokemon.actor.uuid
val currentName = activeBattlePokemon.battlePokemon?.effectedPokemon?.species?.name ?: "unknown"
val lastSwitchTurn = lastVoluntarySwitchTurnByActor[actorId] ?: Int.MIN_VALUE
val switchedLastTurn = battle.turn - lastSwitchTurn <= 1
val pingPongCandidate =
lastVoluntarySwitchToByActor[actorId] == currentName
if (switchedLastTurn && !diagnostics.severeDisadvantage) {
debugLog("Switch Guard: blocked (switched last turn and matchup is not severe).")
} else if (pingPongCandidate && !diagnostics.severeDisadvantage) {
debugLog("Switch Guard: blocked (prevents immediate switch loop).")
} else {
val switchTarget = findBestSwitch(activeBattlePokemon)
if (switchTarget != null) {
switchTarget.willBeSwitchedIn = true
logSwitchDecision("voluntary_switch", availableSwitches = getAvailableSwitches(activeBattlePokemon), chosen = switchTarget)
debugLog("Chosen Action: SWITCH -> ${switchTarget.effectedPokemon.species.name}")
lastVoluntarySwitchTurnByActor[actorId] = battle.turn
lastVoluntarySwitchFromByActor[actorId] = currentName
lastVoluntarySwitchToByActor[actorId] = switchTarget.effectedPokemon.species.name
return SwitchActionResponse(switchTarget.uuid)
}
debugLog("Switch Gate: evaluator requested switch, but no legal target found. Staying in.")
}
} else {
debugLog("Switch Gate: staying in; move evaluation selected instead.")
}
} else {
debugLog("Switch Gate: closed by tempo/skill gate; evaluating moves.")
}
val bestMove = chooseBestMove(moveset, activeBattlePokemon)
logMoveDecision("move_select", bestMove, activeBattlePokemon)
return executeMove(bestMove, activeBattlePokemon)
} catch (e: Exception) {
e.printStackTrace()
return PassActionResponse
}
}
private fun chooseForDoubles(
activeBattlePokemon: ActiveBattlePokemon,
moveset: ShowdownMoveset?
): ShowdownActionResponse {
val state = battleState ?: return PassActionResponse
val plan = currentGamePlan ?: return PassActionResponse
val myPokemon = battleTracker.p2Active.getActiveAt(currentSlot) ?: return PassActionResponse
val partner = battleTracker.p2Active.getActiveAt(1 - currentSlot)
val opponents = battleTracker.p1Active.getAllActive()
val decision = doublesDecisionMaker.findBestMoveAndTarget(
moveset = moveset,
activeBattlePokemon = activeBattlePokemon,
myPokemon = myPokemon,
opponents = opponents,
partner = partner,
battleState = state,
gamePlan = plan
) ?: return makeRandomMove(moveset, activeBattlePokemon)
turnDecisions[currentSlot] = decision
firstSlotDecided = true
debugLog(
"Doubles Decision: move=${decision.move.id} target=${decision.targetPNX ?: "auto"} " +
"score=${"%.2f".format(decision.evaluation.totalScore)}"
)
return if (decision.targetPNX != null) {
MoveActionResponse(decision.move.id, decision.targetPNX)
} else {
MoveActionResponse(decision.move.id)
}
}
private fun initializeComponents(battle: PokemonBattle) {
if (currentBattle != battle) {
currentBattle = battle
isDoublesMode = battle.format.battleType.pokemonPerSide > 1
damageCalculator = NewDamageCalculator(battle)
moveEvaluator = NewMoveEvaluator(damageCalculator, opponentPredictor, isDoublesMode)
switchDecisionMaker = NewSwitchDecisionMaker(damageCalculator, opponentPredictor, strategyAnalyzer)
doublesDecisionMaker = NewDoublesDecisionMaker(moveEvaluator, damageCalculator)
}
}
private fun updateBattleState(activeBattlePokemon: ActiveBattlePokemon, battle: PokemonBattle, aiSide: BattleSide) {
val pnx = activeBattlePokemon.getPNX()
currentSlot = if (pnx.length >= 3) (pnx[2] - 'a').coerceAtLeast(0) else 0
val opponentSide = aiSide.getOppositeSide()
if (isDoublesMode) {
opponentSide.activePokemon.forEachIndexed { index, active ->
active.battlePokemon?.let { battleTracker.updatePokemonSlot(battleTracker.p1Active, index, it) }
}
aiSide.activePokemon.forEachIndexed { index, active ->
active.battlePokemon?.let { battleTracker.updatePokemonSlot(battleTracker.p2Active, index, it) }
}
battleTracker.p1Active.activePokemon =
battleTracker.p1Active.getActiveAt(currentSlot) ?: battleTracker.p1Active.activePokemon
battleTracker.p2Active.activePokemon =
battleTracker.p2Active.getActiveAt(currentSlot) ?: battleTracker.p2Active.activePokemon
} else {
opponentSide.activePokemon.firstOrNull()?.battlePokemon?.let { playerPokemon ->
battleTracker.updatePokemon(battleTracker.p1Active.activePokemon, playerPokemon)
battleTracker.updateParty(battleTracker.p1Active, battleTracker.p1Active.activePokemon)
}
activeBattlePokemon.battlePokemon?.let { aiPokemon ->
battleTracker.updatePokemon(battleTracker.p2Active.activePokemon, aiPokemon)
battleTracker.updateParty(battleTracker.p2Active, battleTracker.p2Active.activePokemon)
}
}
updateRevealedMoves(battle)
battleState = createBattleState(battle, aiSide)
}
private fun createBattleState(battle: PokemonBattle, aiSide: BattleSide): BattleState? {
val opponentSide = aiSide.getOppositeSide()
val playerActor = opponentSide.actors.firstOrNull() ?: return null
val aiActor = aiSide.actors.firstOrNull() ?: return null
val playerActiveTracker = if (isDoublesMode) {
battleTracker.p1Active.getActiveAt(currentSlot) ?: battleTracker.p1Active.activePokemon
} else {
battleTracker.p1Active.activePokemon
}
val npcActiveTracker = if (isDoublesMode) {
battleTracker.p2Active.getActiveAt(currentSlot) ?: battleTracker.p2Active.activePokemon
} else {
battleTracker.p2Active.activePokemon
}
val playerActive = playerActiveTracker.pokemon ?: return null
val npcActive = npcActiveTracker.pokemon ?: return null
val playerActiveSlots = battleTracker.p1Active.getAllActive().mapNotNull { it.pokemon }.ifEmpty { listOf(playerActive) }
val npcActiveSlots = battleTracker.p2Active.getAllActive().mapNotNull { it.pokemon }.ifEmpty { listOf(npcActive) }
val partnerSlot = if (isDoublesMode) 1 - currentSlot else null
return BattleState(
turn = battle.turn,
playerTeam = playerActor.pokemonList.map { it.effectedPokemon },
npcTeam = aiActor.pokemonList.map { it.effectedPokemon },
playerActive = playerActive,
npcActive = npcActive,
weather = getWeather(battle),
terrain = getTerrain(battle),
playerHazards = getHazards(opponentSide),
npcHazards = getHazards(aiSide),
playerBoosts = battleTracker.p1Active.activePokemon.boosts,
npcBoosts = battleTracker.p2Active.activePokemon.boosts,
battleContext = battle.contextManager,
isDoubles = isDoublesMode,
playerActiveSlots = playerActiveSlots,
npcActiveSlots = npcActiveSlots,
mySlotIndex = currentSlot,
partnerSlotIndex = partnerSlot
)
}
private fun handleForcedSwitch(activeBattlePokemon: ActiveBattlePokemon): ShowdownActionResponse {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
if (availableSwitches.isEmpty()) {
debugLog("Forced Switch: no legal switches available; forfeiting.")
return ForfeitActionResponse()
}
val state = battleState
val plan = currentGamePlan
val bestSwitch = if (state != null && plan != null) {
switchDecisionMaker.findBestSwitch(
availableSwitches = availableSwitches,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
} else {
null
}
val switchTarget = bestSwitch ?: availableSwitches.first()
switchTarget.willBeSwitchedIn = true
logSwitchDecision("forced_switch", availableSwitches, switchTarget)
debugLog("Chosen Action: FORCED SWITCH -> ${switchTarget.effectedPokemon.species.name}")
return SwitchActionResponse(switchTarget.uuid)
}
private fun shouldSwitch(activeBattlePokemon: ActiveBattlePokemon): Boolean {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
if (availableSwitches.isEmpty()) return false
val state = battleState ?: return false
val plan = currentGamePlan ?: return false
return switchDecisionMaker.shouldVoluntarilySwitch(
myPokemon = battleTracker.p2Active.activePokemon,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan,
availableSwitches = availableSwitches
)
}
private fun findBestSwitch(activeBattlePokemon: ActiveBattlePokemon): BattlePokemon? {
val availableSwitches = getAvailableSwitches(activeBattlePokemon)
val state = battleState ?: return null
val plan = currentGamePlan ?: return null
return switchDecisionMaker.findBestSwitch(
availableSwitches = availableSwitches,
opponentPokemon = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
}
private fun chooseBestMove(
moveset: ShowdownMoveset?,
activeBattlePokemon: ActiveBattlePokemon
): InBattleMove? {
if (moveset == null) return null
val usableMoves = moveset.moves.filter { it.canBeUsed() }
if (usableMoves.isEmpty()) return null
val state = battleState ?: return null
val plan = currentGamePlan ?: return null
val evaluations = usableMoves.map { move ->
moveEvaluator.evaluateMove(
move = move,
attacker = battleTracker.p2Active.activePokemon,
defender = battleTracker.p1Active.activePokemon,
battleState = state,
gamePlan = plan
)
}
val bestEvaluation = evaluations.maxByOrNull { it.totalScore }
logMoveRanking(evaluations)
lastMoveEvaluation = bestEvaluation
return bestEvaluation?.move
}
private fun executeMove(move: InBattleMove?, activeBattlePokemon: ActiveBattlePokemon): ShowdownActionResponse {
if (move == null) return makeRandomMove(null, activeBattlePokemon)
val target = if (move.mustBeUsed()) null else move.target.targetList(activeBattlePokemon)
return if (target == null) {
debugLog("Chosen Action: MOVE ${move.id} (no target selection needed)")
MoveActionResponse(move.id)
} else {
val chosenTarget = target.filter { !it.isAllied(activeBattlePokemon) }.randomOrNull() ?: target.random()
debugLog("Chosen Action: MOVE ${move.id} -> target ${(chosenTarget as ActiveBattlePokemon).getPNX()}")
MoveActionResponse(move.id, (chosenTarget as ActiveBattlePokemon).getPNX())
}
}
private fun makeRandomMove(moveset: ShowdownMoveset?, activeBattlePokemon: ActiveBattlePokemon): ShowdownActionResponse {
if (moveset == null) {
debugLog("Fallback Action: PASS (no moveset available)")
return PassActionResponse
}
val usableMoves = moveset.moves.filter { it.canBeUsed() }
val move = usableMoves.randomOrNull() ?: run {
debugLog("Fallback Action: PASS (no usable moves)")
return PassActionResponse
}
debugLog("Fallback Action: random move '${move.id}'")
val target = if (move.mustBeUsed()) null else move.target.targetList(activeBattlePokemon)
return if (target == null) {
MoveActionResponse(move.id)
} else {
val chosenTarget = target.filter { !it.isAllied(activeBattlePokemon) }.randomOrNull() ?: target.random()
MoveActionResponse(move.id, (chosenTarget as ActiveBattlePokemon).getPNX())
}
}
private fun updateOpponentModel() {
val situation = categorizeSituation(battleState ?: return)
val opponentActives = if (isDoublesMode) battleTracker.p1Active.getAllActive() else listOf(battleTracker.p1Active.activePokemon)
for (opponentActive in opponentActives) {
for (move in opponentActive.revealedMoves) {
opponentPredictor.updateMoveHistory(
pokemon = opponentActive.species ?: "unknown",
move = move,
situation = situation.name.lowercase()
)
}
}
}
override fun onHealthChange(packet: BattleHealthChangePacket) {
try {
val battle = currentBattle ?: return
val activePokemon = battle.activePokemon.find { it.getPNX() == packet.pnx } ?: return
val newHealth = packet.newHealth.toInt()
val isOpponentPokemon = currentOpponentSide?.actors?.contains(activePokemon.actor) == true
val teamTracker = if (isOpponentPokemon) battleTracker.p1Active else battleTracker.p2Active
val tracker = battleTracker.getTrackedPokemonByPNX(packet.pnx, teamTracker) ?: teamTracker.activePokemon
val oldHealth = tracker.currentHp
val hpChange = newHealth - oldHealth
tracker.previousHp = oldHealth
tracker.currentHp = newHealth
tracker.lastHpChange = hpChange
tracker.currentHpPercent = if (tracker.maxHp > 0) newHealth.toDouble() / tracker.maxHp else 0.0
updateMomentum(hpChange, isOpponentPokemon)
detectCriticalThresholds(tracker, oldHealth, newHealth, isOpponentPokemon)
} catch (_: Exception) {
}
}
private fun updateMomentum(hpChange: Int, isOpponentPokemon: Boolean) {
val momentumChange = when {
hpChange > 0 -> -5.0
hpChange < 0 -> kotlin.math.abs(hpChange) / 10.0
else -> 0.0
}
if (isOpponentPokemon) {
battleTracker.p2Active.momentum += momentumChange
battleTracker.p1Active.momentum -= momentumChange
} else {
battleTracker.p2Active.momentum -= momentumChange
battleTracker.p1Active.momentum += momentumChange
}
battleTracker.p1Active.momentum = battleTracker.p1Active.momentum.coerceIn(-100.0, 100.0)
battleTracker.p2Active.momentum = battleTracker.p2Active.momentum.coerceIn(-100.0, 100.0)
}
private fun detectCriticalThresholds(
tracker: TrackedPokemon,
oldHealth: Int,
newHealth: Int,
isOpponentPokemon: Boolean
) {
if (newHealth <= 0 && oldHealth > 0) {
if (isOpponentPokemon) {
battleTracker.p2Active.momentum += 20.0
} else {
battleTracker.p1Active.momentum += 20.0
}
}
}
private fun categorizeSituation(battleState: BattleState): BattleSituation {
val myHP = battleState.npcActive.currentHealth.toDouble() / battleState.npcActive.maxHealth
val oppHP = battleState.playerActive.currentHealth.toDouble() / battleState.playerActive.maxHealth
return when {
myHP < 0.25 -> BattleSituation.DESPERATE
oppHP < 0.25 -> BattleSituation.WINNING
myHP > 0.8 && oppHP > 0.8 -> BattleSituation.EARLY_GAME
battleState.npcTeam.count { it.currentHealth > 0 } <= 2 -> BattleSituation.ENDGAME
battleState.playerBoosts.values.any { it >= 2 } -> BattleSituation.OPPONENT_SETUP
battleState.npcBoosts.values.any { it >= 2 } -> BattleSituation.MY_SETUP
battleState.weather != null -> BattleSituation.WEATHER_CONTROL
else -> BattleSituation.NEUTRAL
}
}
private fun getAvailableSwitches(activeBattlePokemon: ActiveBattlePokemon): List<BattlePokemon> {
val currentUuid = activeBattlePokemon.battlePokemon?.uuid
return activeBattlePokemon.actor.pokemonList.filter {
it.health > 0 &&
it.uuid != currentUuid &&
try {
it.canBeSentOut()
} catch (_: Exception) {
true
}
}
}
private fun passesSkillCheck(): Boolean {
if (skillLevel == 5) return true
if (skillLevel == 0) return false
val baseChance = skillLevel * 20
val turnBonus = if ((battleState?.turn ?: 0) > 10) 10 else 0
return Random.nextInt(100) < (baseChance + turnBonus)
}
private fun shouldAttemptSwitch(): Boolean {
return when (skillLevel) {
in 0..2 -> false
3 -> Random.nextDouble() < 0.25
4 -> Random.nextDouble() < 0.70
5 -> true
else -> false
}
}
private fun getWeather(battle: PokemonBattle): String? {
val weather = battle.contextManager.get(BattleContext.Type.WEATHER)?.firstOrNull()?.id
return NewAiBattleUtils.normalizeWeatherId(weather)
}
private fun getTerrain(battle: PokemonBattle): String? {
return battle.contextManager.get(BattleContext.Type.TERRAIN)?.firstOrNull()?.id
}
private fun getHazards(side: BattleSide): List<String> {
return side.contextManager.get(BattleContext.Type.HAZARD)?.map { it.id } ?: emptyList()
}
private fun updateRevealedMoves(battle: PokemonBattle) {
updateTeamRevealedMoves(battle, battleTracker.p1Active)
updateTeamRevealedMoves(battle, battleTracker.p2Active)
}
private fun updateTeamRevealedMoves(battle: PokemonBattle, team: TeamTracker) {
val active = team.getAllActive().ifEmpty { listOf(team.activePokemon) }
for (tracked in active) {
val pokemon = tracked.pokemon ?: continue
val majorMessage = battle.majorBattleActions[pokemon.uuid]
val minorMessage = battle.minorBattleActions[pokemon.uuid]
val candidates = listOfNotNull(majorMessage, minorMessage)
for (message in candidates) {
val id = message.id.lowercase()
if (id != "move" && id != "-singlemove" && id != "singlemove") continue
val moveId = message.effectAt(1)?.id?.lowercase() ?: continue
tracked.revealedMoves.add(moveId)
}
}
}
private fun logMoveDecision(
reason: String,
move: InBattleMove?,
activeBattlePokemon: ActiveBattlePokemon
) {
if (!shouldLogDecision() || move == null) return
val eval = lastMoveEvaluation
val self = activeBattlePokemon.battlePokemon?.effectedPokemon?.species?.name ?: "unknown"
val opp = battleTracker.p1Active.activePokemon.species ?: "unknown"
val plan = currentGamePlan?.winCondition?.name ?: "unknown"
if (eval == null) {
debugLog("Move Decision: reason=$reason | self=$self vs $opp | move=${move.id} | plan=$plan")
return
}
debugLog(
"Move Decision: ${move.id} selected | score=${"%.2f".format(eval.totalScore)} " +
"| expectedDamage=${"%.1f".format(eval.expectedDamage)} | strategic=${"%.1f".format(eval.strategicValue)} " +
"| risk=${"%.2f".format(eval.riskFactor)} | context=$self vs $opp | plan=$plan"
)
}
private fun logMoveRanking(evaluations: List<NewMoveEvaluator.MoveEvaluation>) {
if (!shouldLogDecision() || evaluations.isEmpty()) return
val plan = currentGamePlan?.winCondition?.name ?: "unknown"
val situation = battleState?.let { categorizeSituation(it).name } ?: "unknown"
val top = evaluations.sortedByDescending { it.totalScore }.take(DEBUG_TOP_MOVES)
if (!aiDebugEnabled) {
val compact = top.joinToString(" | ") { eval ->
"${eval.move.id}=${"%.1f".format(eval.totalScore)}"
}
debugLog("Move Ranking: plan=$plan, situation=$situation -> $compact")
return
}
val state = battleState
val planData = currentGamePlan
val attacker = battleTracker.p2Active.activePokemon
val defender = battleTracker.p1Active.activePokemon
val details = top.joinToString(", ") { eval ->
val debug = if (state != null && planData != null) {
moveEvaluator.buildDebugDetails(eval.move, attacker, defender, state, planData)
} else {
null
}
val flags = if (debug != null) {
listOf(
"stab=${debug.hasStab}",
"eff=${"%.2f".format(debug.effectiveness)}",
"acc=${"%.2f".format(debug.accuracy)}",
"prio=${debug.priority}",
"status=${debug.isStatusMove}",
"setup=${debug.isSetup}",
"hazardSet=${debug.setsHazards}",
"hazardClear=${debug.removesHazards}",
"weather=${debug.setsWeather}",
"recovery=${debug.isRecovery}",
"pivot=${debug.isPivot}",
"risky=${debug.isRisky}"
).joinToString(" ")
} else {
"n/a"
}
"${eval.move.id}:" +
"score=${"%.2f".format(eval.totalScore)} " +
"raw=${"%.1f".format(eval.rawDamage)} " +
"dmg=${"%.1f".format(eval.expectedDamage)} " +
"strat=${"%.1f".format(eval.strategicValue)} " +
"align=${"%.1f".format(eval.gamePlanAlignment)} " +
"turn=${"%.1f".format(eval.turnAdvantage)} " +
"risk=${"%.2f".format(eval.riskFactor)} " +
flags
}
debugLog("Move Ranking (detailed): plan=$plan situation=$situation top=[$details]")
}
private fun logSwitchDecision(
reason: String,
availableSwitches: List<BattlePokemon>,
chosen: BattlePokemon?
) {
if (!shouldLogDecision()) return
val state = battleState
val plan = currentGamePlan
val opponent = battleTracker.p1Active.activePokemon
val ranked = if (state != null && plan != null) {
availableSwitches.map { candidate ->
val score = switchDecisionMaker.evaluateSwitchOption(
candidate = candidate.effectedPokemon,
opponent = opponent,
battleState = state,
gamePlan = plan
)
candidate to score
}.sortedByDescending { it.second }
.take(3)
.joinToString(", ") { (candidate, score) ->
"${candidate.effectedPokemon.species.name}:${"%.1f".format(score)}"
}
} else {
"n/a"
}
val chosenName = chosen?.effectedPokemon?.species?.name ?: "none"
debugLog("Switch Decision: reason=$reason | chosen=$chosenName | options=[$ranked]")
}
private fun debugLog(message: String) {
if (!shouldLogDecision()) return
val battleIdShort = currentBattle?.battleId?.toString()?.take(8) ?: "unknown"
val turn = currentBattle?.turn ?: -1
Cobblemon.LOGGER.info("[NewCompetitiveBattleAI battle=$battleIdShort turn=$turn] $message")
}
private fun isAiDebugEnabled(): Boolean {
return System.getProperty("cobblemon.ai.debug", "false").toBooleanStrictOrNull() ?: false
}
private fun shouldLogDecision(): Boolean {
val battle = currentBattle
val isNpcVsNpc = battle?.actors?.all { it.type == ActorType.NPC } == true
return aiDebugEnabled || isNpcVsNpc
}
private fun logDecisionContext(activeBattlePokemon: ActiveBattlePokemon, forceSwitch: Boolean) {
if (!shouldLogDecision()) return
val self = activeBattlePokemon.battlePokemon?.effectedPokemon?.species?.name ?: "unknown"
val opp = battleTracker.p1Active.activePokemon.species ?: "unknown"
val selfHp = formatPct(battleTracker.p2Active.activePokemon.currentHpPercent)
val oppHp = formatPct(battleTracker.p1Active.activePokemon.currentHpPercent)
val plan = currentGamePlan?.winCondition?.name ?: "unknown"
val situation = battleState?.let { categorizeSituation(it).name } ?: "unknown"
val gamePhase = battleState?.gamePhase?.name ?: "unknown"
debugLog(
"Context: $self ($selfHp) vs $opp ($oppHp) | plan=$plan | situation=$situation | phase=$gamePhase " +
"| forceSwitch=$forceSwitch | doubles=$isDoublesMode | skill=$skillLevel"
)
}
private fun formatPct(value: Double): String {
val pct = (value.coerceIn(0.0, 1.0) * 100).toInt()
return "$pct%"
}
}
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,7 @@ import com.cobblemon.mod.common.api.types.ElementalTypes
import com.cobblemon.mod.common.battles.*
import com.cobblemon.mod.common.battles.ai.strongBattleAI.AIUtility
import com.cobblemon.mod.common.battles.ai.strongBattleAI.ActiveTracker
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
import com.cobblemon.mod.common.battles.ai.strongBattleAI.TrackerPokemon
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.pokemon.status.PersistentStatus
@@ -174,6 +175,10 @@ class StrongBattleAI(skill: Int) : BattleAI {
(opponent.pokemon?.status?.status?.showdownName ?: opponent.currentStatus) == Statuses.BURN.showdownName && moveData.damageCategory == DamageCategories.PHYSICAL -> 0.5
else -> 1.0
}
val abilityMoveTagMultiplier = BattleAITags.abilityMoveTagDamageMultiplier(
attacker.currentAbility?.name ?: attacker.pokemon?.ability?.name,
moveData.name
)
val hitsExpected = expectedHits(moveData)
var damage = (((((2 * pokemonLevel) / 5 ) + 2) * movePower * statRatio) / 50 + 2)
@@ -181,6 +186,7 @@ class StrongBattleAI(skill: Int) : BattleAI {
damage *= STAB
damage *= damageTypeMultiplier
damage *= burn
damage *= abilityMoveTagMultiplier
damage *= hitsExpected
return damage
@@ -990,4 +996,4 @@ class StrongBattleAI(skill: Int) : BattleAI {
return MoveActionResponse(move.id, chosenTarget.getPNX())
}
}
}
}
@@ -0,0 +1,36 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
object AiBattleUtils {
fun normalizeWeatherId(weather: String?): String? {
val id = weather?.lowercase() ?: return null
return when (id) {
"sun", "sunny", "sunnyday", "harshsunlight", "desolateland" -> "sunnyday"
"rain", "raindance", "heavyrain", "primordialsea", "raining" -> "raindance"
"sand", "sandstorm" -> "sandstorm"
"hail" -> "hail"
"snow", "snowscape" -> "snow"
else -> id
}
}
fun hasStab(moveTypeName: String, pokemonTypes: List<String>): Boolean {
val moveType = moveTypeName.lowercase()
return pokemonTypes.any { it.lowercase() == moveType }
}
fun parseTagPercent(tag: String, prefix: String): Int? {
if (!tag.startsWith(prefix)) {
return null
}
return tag.removePrefix(prefix).toIntOrNull()
}
fun abilityHasTag(abilityId: String?, tag: String): Boolean {
if (abilityId.isNullOrBlank()) {
return false
}
return tag.lowercase() in BattleAITags.getAbilityTags(abilityId)
}
}
@@ -0,0 +1,219 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Move
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.interpreter.ContextManager
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.pokemon.Pokemon
data class BattleState(
val turn: Int,
val playerTeam: List<Pokemon>,
val npcTeam: List<Pokemon>,
val playerActive: Pokemon,
val npcActive: Pokemon,
val weather: String?,
val terrain: String?,
val playerHazards: List<String>,
val npcHazards: List<String>,
val playerBoosts: Map<Stat, Int>,
val npcBoosts: Map<Stat, Int>,
val battleContext: ContextManager,
val gamePhase: GamePhase = determineGamePhase(turn, npcTeam, playerTeam),
val isDoubles: Boolean = false,
val playerActiveSlots: List<Pokemon> = listOf(playerActive),
val npcActiveSlots: List<Pokemon> = listOf(npcActive),
val mySlotIndex: Int = 0,
val partnerSlotIndex: Int? = null
) {
companion object {
private fun determineGamePhase(turn: Int, npcTeam: List<Pokemon>, playerTeam: List<Pokemon>): GamePhase {
val npcAlive = npcTeam.count { it.currentHealth > 0 }
val playerAlive = playerTeam.count { it.currentHealth > 0 }
return when {
turn <= 3 -> GamePhase.EARLY_GAME
npcAlive <= 2 || playerAlive <= 2 -> GamePhase.ENDGAME
turn >= 15 -> GamePhase.LATE_GAME
else -> GamePhase.MID_GAME
}
}
}
}
enum class GamePhase {
EARLY_GAME,
MID_GAME,
LATE_GAME,
ENDGAME
}
data class TrackedPokemon(
var pokemon: Pokemon? = null,
var originalPokemon: Pokemon? = null,
var species: String? = null,
var currentHp: Int = 0,
var currentHpPercent: Double = 0.0,
var maxHp: Int = 0,
var previousHp: Int = 0,
var lastHpChange: Int = 0,
var boosts: Map<Stat, Int> = mapOf(),
var atkBoost: Int = 0,
var defBoost: Int = 0,
var spaBoost: Int = 0,
var spdBoost: Int = 0,
var speBoost: Int = 0,
var currentAbility: String? = null,
var currentTypes: MutableList<String>? = null,
var stats: Map<Stat, Int> = mapOf(),
var moves: List<Move> = listOf(),
var revealedMoves: MutableSet<String> = mutableSetOf(),
var cachedAttack: Double = 0.0,
var cachedDefense: Double = 0.0,
var cachedSpAttack: Double = 0.0,
var cachedSpDefense: Double = 0.0,
var cachedSpeed: Double = 0.0,
var sideConditions: Map<String, Any> = mapOf(),
var firstTurn: Int = 0,
var protectCount: Int = 0,
var lastMoveDamage: Int = 0,
var statusTurns: Int = 0,
var switchInTurn: Int = 0,
var predictedRole: PokemonRole = PokemonRole.UNKNOWN,
var threatLevel: Int = 0
)
enum class PokemonRole {
UNKNOWN,
SWEEPER,
SETUP_SWEEPER,
WALLBREAKER,
REVENGE_KILLER,
TANK,
SUPPORT,
PIVOT,
BALANCED
}
data class TeamTracker(
var party: MutableList<TrackedPokemon> = mutableListOf(),
var uuid: String? = null,
var activePokemon: TrackedPokemon = TrackedPokemon(),
var activeSlots: MutableList<TrackedPokemon> = mutableListOf(),
var nRemainingMons: Int = 0,
var hazardDamage: Int = 0,
var teamSynergy: Double = 0.0,
var momentum: Double = 0.0
) {
fun getActiveAt(slotIndex: Int): TrackedPokemon? = activeSlots.getOrNull(slotIndex)
fun getAllActive(): List<TrackedPokemon> = activeSlots.filter { it.currentHp > 0 }
fun syncLegacyActivePokemon() {
if (activeSlots.isNotEmpty()) {
activePokemon = activeSlots[0]
}
}
}
class EnhancedActiveTracker {
var p1Active: TeamTracker = TeamTracker()
var p2Active: TeamTracker = TeamTracker()
fun updatePokemonSlot(team: TeamTracker, slotIndex: Int, battlePokemon: BattlePokemon) {
while (team.activeSlots.size <= slotIndex) {
team.activeSlots.add(TrackedPokemon())
}
updatePokemon(team.activeSlots[slotIndex], battlePokemon)
team.syncLegacyActivePokemon()
}
fun getTrackedPokemonByPNX(pnx: String, team: TeamTracker): TrackedPokemon? {
if (pnx.length < 3) return null
val slotIndex = pnx[2] - 'a'
return team.getActiveAt(slotIndex)
}
fun updatePokemon(tracker: TrackedPokemon, battlePokemon: BattlePokemon) {
val pokemon = battlePokemon.effectedPokemon
tracker.pokemon = pokemon
tracker.originalPokemon = battlePokemon.originalPokemon
tracker.species = pokemon.species.name
tracker.currentHp = pokemon.currentHealth
tracker.maxHp = pokemon.maxHealth
tracker.currentHpPercent = pokemon.currentHealth.toDouble() / pokemon.maxHealth
tracker.currentTypes = pokemon.types.map { it.name.lowercase() }.toMutableList()
tracker.currentAbility = pokemon.ability.name
tracker.moves = pokemon.moveSet.getMoves()
tracker.boosts = battlePokemon.statChanges
tracker.atkBoost = battlePokemon.statChanges[Stats.ATTACK] ?: 0
tracker.defBoost = battlePokemon.statChanges[Stats.DEFENCE] ?: 0
tracker.spaBoost = battlePokemon.statChanges[Stats.SPECIAL_ATTACK] ?: 0
tracker.spdBoost = battlePokemon.statChanges[Stats.SPECIAL_DEFENCE] ?: 0
tracker.speBoost = battlePokemon.statChanges[Stats.SPEED] ?: 0
cacheStats(tracker)
}
private fun cacheStats(tracker: TrackedPokemon) {
val pokemon = tracker.pokemon ?: return
tracker.cachedAttack = calculateStat(pokemon, Stats.ATTACK)
tracker.cachedDefense = calculateStat(pokemon, Stats.DEFENCE)
tracker.cachedSpAttack = calculateStat(pokemon, Stats.SPECIAL_ATTACK)
tracker.cachedSpDefense = calculateStat(pokemon, Stats.SPECIAL_DEFENCE)
tracker.cachedSpeed = calculateStat(pokemon, Stats.SPEED)
}
private fun calculateStat(pokemon: Pokemon, stat: Stat): Double {
val base = pokemon.species.baseStats[stat] ?: 0
val iv = pokemon.ivs[stat] ?: 31
val ev = pokemon.evs[stat] ?: 0
val level = pokemon.level
return ((2.0 * base + iv + ev / 4.0) * level / 100.0 + 5.0)
}
fun updateParty(team: TeamTracker, pokemon: TrackedPokemon) {
val existingIndex = team.party.indexOfFirst { it.pokemon?.uuid == pokemon.pokemon?.uuid }
if (existingIndex == -1) {
team.party.add(pokemon)
} else {
team.party[existingIndex] = pokemon
}
}
}
data class FearThreat(
val pokemon: Pokemon,
val hasEndeavor: Boolean,
val hasPriorityMove: Boolean,
val hasFocusItem: Boolean,
val threatLevel: Int
)
enum class BattleSituation {
DESPERATE,
WINNING,
EARLY_GAME,
ENDGAME,
OPPONENT_SETUP,
MY_SETUP,
NEUTRAL,
HAZARD_ADVANTAGE,
WEATHER_CONTROL
}
data class MatchupEvaluation(
val offensiveAdvantage: Double,
val defensiveAdvantage: Double,
val speedAdvantage: Double,
val overallScore: Double,
val recommendation: MatchupRecommendation
)
enum class MatchupRecommendation {
STAY_IN_AND_ATTACK,
STAY_IN_AND_SETUP,
STAY_IN_AND_STATUS,
CONSIDER_SWITCHING,
SWITCH_OUT,
PIVOT_OUT
}
@@ -0,0 +1,474 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.battles.interpreter.BattleContext
import com.cobblemon.mod.common.api.moves.MoveTemplate
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.api.moves.categories.DamageCategories
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.api.types.ElementalTypes
import com.cobblemon.mod.common.battles.InBattleMove
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
import com.cobblemon.mod.common.pokemon.Pokemon
import net.minecraft.core.registries.BuiltInRegistries
import java.util.UUID
import kotlin.math.max
class DamageCalculator(
private val battle: PokemonBattle?,
private val consumedItems: MutableMap<UUID, MutableList<String>> = mutableMapOf()
) {
fun calculateDamage(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
weather: String? = null,
criticalHit: Boolean = false,
isSpreadMove: Boolean = false,
targetCount: Int = 1
): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
if (moveData.power <= 0) return 0.0
var power = moveData.power.toDouble()
if (isSpreadMove && targetCount > 1) {
power *= 0.75
}
if (checkAllImmunities(moveData, attacker, defender) == 0.0) {
return 0.0
}
val level = attacker.pokemon?.level ?: 50
val attackStat = getAttackStat(moveData, attacker).coerceAtLeast(1.0)
val defenseStat = getDefenseStat(moveData, defender).coerceAtLeast(1.0)
var damage = ((2.0 * level / 5.0 + 2.0) * power * (attackStat / defenseStat)) / 50.0 + 2.0
damage *= calculateCriticalMultiplier(criticalHit)
damage *= calculateSTABMultiplier(attacker, moveData)
damage *= TypeEffectiveness.calculateTypeEffectiveness(moveData.elementalType, defender.currentTypes ?: listOf())
damage *= calculateWeatherMultiplier(moveData, weather)
damage *= calculateAbilityMultiplier(attacker, defender, moveData)
damage *= calculateItemMultiplier(attacker, defender, moveData)
damage *= calculateBurnMultiplier(attacker, moveData)
damage *= calculateScreenMultiplier(defender, moveData)
damage *= calculateRandomFactor()
damage = calculateMultiHitDamage(moveData, damage)
val finalDamage = max(1.0, damage)
if (finalDamage > 0) {
trackConsumedItems(defender, moveData)
}
return finalDamage
}
private fun getAttackStat(moveData: MoveTemplate, attacker: TrackedPokemon): Double {
val pokemon = attacker.pokemon ?: return 100.0
val baseStat = if (moveData.damageCategory == DamageCategories.PHYSICAL) {
getEffectiveStat(pokemon, Stats.ATTACK)
} else {
getEffectiveStat(pokemon, Stats.SPECIAL_ATTACK)
}
val boost = if (moveData.damageCategory == DamageCategories.PHYSICAL) attacker.atkBoost else attacker.spaBoost
return applyStatBoosts(baseStat, boost)
}
private fun getDefenseStat(moveData: MoveTemplate, defender: TrackedPokemon): Double {
val pokemon = defender.pokemon ?: return 100.0
val baseStat = if (moveData.damageCategory == DamageCategories.PHYSICAL) {
getEffectiveStat(pokemon, Stats.DEFENCE)
} else {
getEffectiveStat(pokemon, Stats.SPECIAL_DEFENCE)
}
val boost = if (moveData.damageCategory == DamageCategories.PHYSICAL) defender.defBoost else defender.spdBoost
return applyStatBoosts(baseStat, boost)
}
private fun applyStatBoosts(baseStat: Double, boost: Int): Double {
val clampedBoost = boost.coerceIn(-6, 6)
return if (clampedBoost >= 0) {
baseStat * (2.0 + clampedBoost) / 2.0
} else {
baseStat * 2.0 / (2.0 - clampedBoost)
}
}
private fun getEffectiveStat(pokemon: Pokemon, stat: Stat): Double {
val base = pokemon.species.baseStats[stat] ?: 0
val iv = pokemon.ivs[stat] ?: 31
val ev = pokemon.evs[stat] ?: 0
val level = pokemon.level
return if (stat == Stats.HP) {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + level + 10.0)
} else {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + 5.0)
}
}
private fun checkAllImmunities(
moveData: MoveTemplate,
attacker: TrackedPokemon,
defender: TrackedPokemon
): Double {
if (checkTypeImmunity(moveData, attacker, defender)) return 0.0
if (checkAbilityImmunity(moveData, attacker, defender)) return 0.0
if (checkItemImmunity(moveData, defender)) return 0.0
if (checkMoveSpecificImmunity(moveData, defender)) return 0.0
return 1.0
}
private fun checkTypeImmunity(
moveData: MoveTemplate,
attacker: TrackedPokemon,
defender: TrackedPokemon
): Boolean {
val defenderTypes = (defender.currentTypes ?: defender.pokemon?.types?.map { it.name } ?: listOf())
.map { it.lowercase() }
val attackerAbilityTags = BattleAITags.getAbilityTags(
attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
)
val ignoresGhostImmunity = "ignore_ghost_immunity_normal_fighting" in attackerAbilityTags
return when (moveData.elementalType) {
ElementalTypes.GROUND -> "flying" in defenderTypes
ElementalTypes.ELECTRIC -> "ground" in defenderTypes
ElementalTypes.NORMAL, ElementalTypes.FIGHTING -> "ghost" in defenderTypes && !ignoresGhostImmunity
ElementalTypes.PSYCHIC -> "dark" in defenderTypes
ElementalTypes.POISON -> "steel" in defenderTypes
ElementalTypes.DRAGON -> "fairy" in defenderTypes
ElementalTypes.GHOST -> "normal" in defenderTypes
else -> false
}
}
private fun checkAbilityImmunity(
moveData: MoveTemplate,
attacker: TrackedPokemon,
defender: TrackedPokemon
): Boolean {
val defenderAbility = defender.currentAbility?.lowercase()
?: defender.pokemon?.ability?.name?.lowercase()
?: return false
val attackerAbility = attacker.currentAbility?.lowercase()
?: attacker.pokemon?.ability?.name?.lowercase()
val moveTags = BattleAITags.getMoveTags(moveData.name)
val defenderAbilityTags = BattleAITags.getAbilityTags(defenderAbility)
val attackerAbilityTags = BattleAITags.getAbilityTags(attackerAbility)
val ignoresAbility = "ignores_ability" in moveTags || "ignores_ability_effects" in attackerAbilityTags
if (ignoresAbility && defenderAbility != "wonderguard") {
return false
}
if ("immune_type_${moveData.elementalType.name.lowercase()}" in defenderAbilityTags) {
return true
}
defenderAbilityTags
.filter { it.startsWith("immune_move_tag_") }
.forEach { immunityTag ->
val guardedTag = immunityTag.removePrefix("immune_move_tag_")
if (guardedTag in moveTags) {
return true
}
}
if ("blocks_opponent_priority" in defenderAbilityTags && moveData.priority > 0) {
return true
}
if ("status_move_immunity" in defenderAbilityTags && moveData.power <= 0) {
return true
}
if (defenderAbilityTags.isNotEmpty()) {
return false
}
// Legacy fallback when ability tags are not present for this ability id.
return when (defenderAbility) {
"levitate" -> moveData.elementalType == ElementalTypes.GROUND
"voltabsorb", "lightningrod", "motordrive" -> moveData.elementalType == ElementalTypes.ELECTRIC
"flashfire", "wellbakedbody" -> moveData.elementalType == ElementalTypes.FIRE
"waterabsorb", "stormdrain", "dryskin" -> moveData.elementalType == ElementalTypes.WATER
"sapsipper" -> moveData.elementalType == ElementalTypes.GRASS
"eartheater" -> moveData.elementalType == ElementalTypes.GROUND
"soundproof" -> isSoundMove(moveData)
"bulletproof" -> isBallOrBombMove(moveData)
"queenlymajesty", "dazzling", "armortail" -> moveData.priority > 0
"wonderguard" -> {
val effectiveness = TypeEffectiveness.calculateTypeEffectiveness(
moveData.elementalType,
defender.currentTypes ?: listOf()
)
effectiveness <= 1.0
}
"goodasgold" -> moveData.power.toInt() == 0
else -> false
}
}
private fun checkItemImmunity(moveData: MoveTemplate, defender: TrackedPokemon): Boolean {
val defenderItem = defender.pokemon?.heldItem()?.item?.let {
BuiltInRegistries.ITEM.getKey(it).toString()
} ?: return false
return when (defenderItem) {
"cobblemon:air_balloon" -> {
val notConsumed = consumedItems[defender.pokemon?.uuid]?.contains("airballoon") != true
notConsumed && moveData.elementalType == ElementalTypes.GROUND
}
"cobblemon:safety_goggles" -> isPowderMove(moveData)
else -> false
}
}
private fun checkMoveSpecificImmunity(moveData: MoveTemplate, defender: TrackedPokemon): Boolean {
val defenderTypes = (defender.currentTypes ?: defender.pokemon?.types?.map { it.name } ?: listOf())
.map { it.lowercase() }
val defenderAbility = defender.currentAbility?.lowercase()
?: defender.pokemon?.ability?.name?.lowercase()
val defenderAbilityTags = BattleAITags.getAbilityTags(defenderAbility)
val moveTags = BattleAITags.getMoveTags(moveData.name)
if ("powder" in moveTags && "grass" in defenderTypes) {
return true
}
val inflictsParalysis = moveTags.any { it.startsWith("paralysis_") }
val inflictsBurn = moveTags.any { it.startsWith("burn_") }
val inflictsToxic = moveTags.any { it.startsWith("toxic_") }
val inflictsPoison = inflictsToxic || moveTags.any { it.startsWith("poison_") }
val inflictsSleep = moveTags.any { it.startsWith("sleep_") }
if ("status_move_immunity" in defenderAbilityTags && moveData.power <= 0 &&
(inflictsParalysis || inflictsBurn || inflictsPoison || inflictsSleep)
) {
return true
}
return when {
inflictsParalysis -> {
"electric" in defenderTypes ||
"ground" in defenderTypes ||
defenderAbility in listOf("limber", "voltabsorb", "lightningrod", "motordrive")
}
inflictsBurn -> {
"fire" in defenderTypes ||
defenderAbility in listOf("waterveil", "waterbubble", "flashfire")
}
inflictsPoison -> {
"poison" in defenderTypes ||
"steel" in defenderTypes ||
defenderAbility in listOf("immunity", "poisonheal", "pastelveil")
}
inflictsSleep -> {
defenderAbility in listOf("insomnia", "vitalspirit", "comatose", "sweetveil")
}
else -> false
}
}
private fun calculateCriticalMultiplier(criticalHit: Boolean): Double = if (criticalHit) 1.5 else 1.0
private fun calculateSTABMultiplier(attacker: TrackedPokemon, moveData: MoveTemplate): Double {
val pokemonTypes = attacker.currentTypes ?: attacker.pokemon?.types?.map { it.name.lowercase() } ?: return 1.0
val attackerAbility = attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
val attackerAbilityTags = BattleAITags.getAbilityTags(attackerAbility)
return if (AiBattleUtils.hasStab(moveData.elementalType.name, pokemonTypes)) {
if ("stab_boost" in attackerAbilityTags) 2.0 else 1.5
} else {
1.0
}
}
private fun calculateWeatherMultiplier(moveData: MoveTemplate, weather: String?): Double {
return when (AiBattleUtils.normalizeWeatherId(weather)) {
"sunnyday" -> when {
moveData.elementalType == ElementalTypes.FIRE -> 1.5
moveData.elementalType == ElementalTypes.WATER && moveData.name != "hydrosteam" -> 0.5
moveData.name == "hydrosteam" -> 1.5
else -> 1.0
}
"raindance" -> when {
moveData.elementalType == ElementalTypes.WATER -> 1.5
moveData.elementalType == ElementalTypes.FIRE -> 0.5
else -> 1.0
}
"sandstorm", "hail", "snow" -> if (moveData.name == "weatherball") 2.0 else 1.0
else -> 1.0
}
}
private fun calculateAbilityMultiplier(
attacker: TrackedPokemon,
defender: TrackedPokemon,
moveData: MoveTemplate
): Double {
var multiplier = 1.0
val attackerAbility = attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
val defenderAbility = defender.currentAbility?.lowercase() ?: defender.pokemon?.ability?.name?.lowercase()
val attackerAbilityTags = BattleAITags.getAbilityTags(attackerAbility)
val defenderTypes = defender.currentTypes ?: listOf()
val activeWeather = AiBattleUtils.normalizeWeatherId(
battle?.contextManager?.get(BattleContext.Type.WEATHER)?.firstOrNull()?.id
)
val moveType = moveData.elementalType.name.lowercase()
if ("conditional_type_boost" in attackerAbilityTags) {
val hpConditionMet = "conditional_hp_low" !in attackerAbilityTags || attacker.currentHpPercent <= 0.33
val weatherCondition = attackerAbilityTags
.firstOrNull { it.startsWith("conditional_weather_") }
?.removePrefix("conditional_weather_")
val weatherConditionMet = weatherCondition == null ||
AiBattleUtils.normalizeWeatherId(weatherCondition) == activeWeather
val boostedTypes = attackerAbilityTags
.filter { it.startsWith("type_") }
.map { it.removePrefix("type_") }
.toSet()
if (hpConditionMet && weatherConditionMet && (boostedTypes.isEmpty() || moveType in boostedTypes)) {
multiplier *= 1.5
}
}
if ("physical_boost" in attackerAbilityTags) {
val statusName = attacker.pokemon?.status?.status?.showdownName?.lowercase()
val conditionMet = when {
"conditional_status" in attackerAbilityTags -> statusName != null
"conditional_poison" in attackerAbilityTags ->
statusName == "poison" || statusName == "tox" || statusName == "poisonbadly"
"conditional_burn" in attackerAbilityTags -> statusName == "burn"
else -> true
}
if (conditionMet && moveData.damageCategory == DamageCategories.PHYSICAL) {
multiplier *= 1.5
}
}
when (defenderAbility) {
"thickfat" -> if (moveData.elementalType == ElementalTypes.FIRE || moveData.elementalType == ElementalTypes.ICE) multiplier *= 0.5
"filter", "solidrock" -> {
val effectiveness = TypeEffectiveness.calculateTypeEffectiveness(moveData.elementalType, defenderTypes)
if (effectiveness > 1.0) multiplier *= 0.75
}
}
multiplier *= BattleAITags.abilityMoveTagDamageMultiplier(attackerAbility, moveData.name)
return multiplier
}
private fun calculateItemMultiplier(
attacker: TrackedPokemon,
defender: TrackedPokemon,
moveData: MoveTemplate
): Double {
var multiplier = 1.0
val attackerItem = attacker.pokemon?.heldItem()?.item?.let {
BuiltInRegistries.ITEM.getKey(it).toString()
}
when (attackerItem) {
"cobblemon:choice_band" -> if (moveData.damageCategory == DamageCategories.PHYSICAL) multiplier *= 1.5
"cobblemon:choice_specs" -> if (moveData.damageCategory == DamageCategories.SPECIAL) multiplier *= 1.5
"cobblemon:life_orb" -> multiplier *= 1.3
}
return multiplier
}
private fun calculateBurnMultiplier(attacker: TrackedPokemon, moveData: MoveTemplate): Double {
val attackerAbility = attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
val attackerAbilityTags = BattleAITags.getAbilityTags(attackerAbility)
val ignoresBurnPenalty = "conditional_status" in attackerAbilityTags && "physical_boost" in attackerAbilityTags
return if (attacker.pokemon?.status?.status?.showdownName == "burn" &&
moveData.damageCategory == DamageCategories.PHYSICAL &&
!ignoresBurnPenalty
) {
0.5
} else {
1.0
}
}
private fun calculateScreenMultiplier(defender: TrackedPokemon, moveData: MoveTemplate): Double {
val sideConditions = defender.sideConditions.keys.map { it.lowercase() }
val hasReflect = "reflect" in sideConditions
val hasLightScreen = "lightscreen" in sideConditions || "light screen" in sideConditions
val hasAuroraVeil = "auroraveil" in sideConditions || "aurora veil" in sideConditions
return when (moveData.damageCategory) {
DamageCategories.PHYSICAL -> when {
hasAuroraVeil || hasReflect -> 0.5
else -> 1.0
}
DamageCategories.SPECIAL -> when {
hasAuroraVeil || hasLightScreen -> 0.5
else -> 1.0
}
else -> 1.0
}
}
private fun calculateRandomFactor(): Double = 0.925
private fun calculateMultiHitDamage(moveData: MoveTemplate, baseDamage: Double): Double {
val moveName = moveData.name.lowercase()
return when {
moveName == "populationbomb" -> {
val accuracy = moveData.accuracy / 100.0
var expectedDamage = 0.0
var probability = 1.0
for (hit in 1..10) {
expectedDamage += baseDamage * probability
probability *= accuracy
}
expectedDamage
}
moveName in listOf("triplekick", "tripleaxel") -> {
val accuracy = moveData.accuracy / 100.0
baseDamage + baseDamage * 2.0 * accuracy + baseDamage * 3.0 * accuracy * accuracy
}
moveName in MoveData.multiHitMoves.keys -> {
val hitRange = MoveData.multiHitMoves[moveName] ?: mapOf(1 to 1)
val minHits = hitRange.keys.minOrNull() ?: 1
val maxHits = hitRange.values.maxOrNull() ?: 1
when {
minHits == 2 && maxHits == 5 -> baseDamage * 3.0
minHits == maxHits -> baseDamage * minHits
else -> baseDamage * ((minHits + maxHits) / 2.0)
}
}
else -> baseDamage
}
}
private fun isSoundMove(moveData: MoveTemplate): Boolean {
val tags = BattleAITags.getMoveTags(moveData.name)
return "sound" in tags
}
private fun isBallOrBombMove(moveData: MoveTemplate): Boolean {
val tags = BattleAITags.getMoveTags(moveData.name)
return "bullet" in tags
}
private fun isPowderMove(moveData: MoveTemplate): Boolean {
val tags = BattleAITags.getMoveTags(moveData.name)
return "powder" in tags
}
private fun trackConsumedItems(defender: TrackedPokemon, moveData: MoveTemplate) {
val pokemon = defender.pokemon ?: return
val defenderItem = pokemon.heldItem()?.item?.let {
BuiltInRegistries.ITEM.getKey(it).toString()
} ?: return
if (defenderItem == "cobblemon:air_balloon") {
val alreadyConsumed = consumedItems[pokemon.uuid]?.contains("airballoon") == true
if (!alreadyConsumed) {
addConsumedItem(pokemon.uuid, "airballoon")
}
}
}
private fun addConsumedItem(pokemonUuid: UUID, item: String) {
val items = consumedItems.getOrPut(pokemonUuid) { mutableListOf() }
items.add(item)
}
}
@@ -0,0 +1,114 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.battles.ActiveBattlePokemon
import com.cobblemon.mod.common.battles.InBattleMove
import com.cobblemon.mod.common.battles.ShowdownMoveset
class DoublesDecisionMaker(
private val moveEvaluator: MoveEvaluator,
private val damageCalculator: DamageCalculator
) {
data class MoveTargetPair(
val move: InBattleMove,
val targetPNX: String?,
val evaluation: MoveEvaluator.MoveEvaluation
)
fun findBestMoveAndTarget(
moveset: ShowdownMoveset?,
activeBattlePokemon: ActiveBattlePokemon,
myPokemon: TrackedPokemon,
opponents: List<TrackedPokemon>,
partner: TrackedPokemon?,
battleState: BattleState,
gamePlan: GamePlan
): MoveTargetPair? {
if (moveset == null) return null
val usableMoves = moveset.moves.filter { it.canBeUsed() }
if (usableMoves.isEmpty()) return null
val allOptions = mutableListOf<MoveTargetPair>()
for (move in usableMoves) {
Moves.getByName(move.id) ?: continue
val targetList = move.target.targetList(activeBattlePokemon)
if (targetList == null) {
val evaluation = moveEvaluator.evaluateMoveForDoubles(
move,
myPokemon,
opponents.firstOrNull() ?: TrackedPokemon(),
opponents,
partner,
battleState,
gamePlan
)
allOptions.add(MoveTargetPair(move, null, evaluation))
continue
}
val opponentTargets = targetList.filterIsInstance<ActiveBattlePokemon>()
.filter { !it.isAllied(activeBattlePokemon) }
for (target in opponentTargets) {
val targetPokemon = opponents.find { it.pokemon?.uuid == target.battlePokemon?.uuid } ?: continue
val evaluation = moveEvaluator.evaluateMoveForDoubles(
move,
myPokemon,
targetPokemon,
opponents,
partner,
battleState,
gamePlan
)
allOptions.add(MoveTargetPair(move, target.getPNX(), evaluation))
}
}
return allOptions.maxByOrNull { it.evaluation.totalScore }
}
fun scoreTarget(
target: TrackedPokemon,
myPokemon: TrackedPokemon,
allOpponents: List<TrackedPokemon>,
battleState: BattleState
): Double {
var score = 0.0
score += target.threatLevel * 20.0
val mySpeed = myPokemon.cachedSpeed
val targetSpeed = target.cachedSpeed
if (targetSpeed > mySpeed && target.currentHpPercent < 0.5) {
score += 100.0
}
if (target.boosts.values.any { it >= 2 }) {
score += 120.0
}
if (target.cachedAttack > 100 || target.cachedSpAttack > 100) {
score += 40.0
}
if (target.currentHpPercent < 0.3) {
score += 80.0
}
return score
}
fun selectBestTarget(
move: InBattleMove,
activeBattlePokemon: ActiveBattlePokemon,
myPokemon: TrackedPokemon,
opponents: List<TrackedPokemon>,
battleState: BattleState
): String? {
val targetList = move.target.targetList(activeBattlePokemon) ?: return null
val opponentTargets = targetList.filterIsInstance<ActiveBattlePokemon>()
.filter { !it.isAllied(activeBattlePokemon) }
if (opponentTargets.isEmpty()) return null
val targetScores = opponentTargets.mapNotNull { target ->
val targetPokemon = opponents.find { it.pokemon?.uuid == target.battlePokemon?.uuid } ?: return@mapNotNull null
val damage = damageCalculator.calculateDamage(move, myPokemon, targetPokemon, battleState.weather)
val targetScore = scoreTarget(targetPokemon, myPokemon, opponents, battleState)
target to (damage + targetScore)
}
return targetScores.maxByOrNull { it.second }?.first?.getPNX()
}
}
@@ -0,0 +1,190 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.api.pokemon.status.Statuses
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
object MoveData {
private val statTagRegex = Regex("^stat_(atk|def|spatk|spdef|speed|accuracy|evasion)_(-?\\d+)(?:_(\\d+))?$")
private val knownAntiHazardsFallback = listOf("rapidspin", "defog", "tidyup")
private val knownAntiBoostFallback = listOf("clearsmog", "haze", "roar", "whirlwind")
private fun moveTags(moveId: String): Set<String> = BattleAITags.getMoveTags(moveId)
private fun allMoveIds(): List<String> = Moves.all().map { it.name.lowercase() }
private fun hasAnyTag(moveId: String, tags: Set<String>): Boolean {
val moveTags = moveTags(moveId)
return tags.any { it in moveTags }
}
val multiHitMoves: Map<String, Map<Int, Int>> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
val min = tags.firstOrNull { it.startsWith("multi_hit_min_") }?.removePrefix("multi_hit_min_")?.toIntOrNull()
val max = tags.firstOrNull { it.startsWith("multi_hit_max_") }?.removePrefix("multi_hit_max_")?.toIntOrNull()
when {
min != null && max != null -> moveId to mapOf(min to max)
"multi_hit" in tags -> moveId to mapOf(2 to 5)
else -> null
}
}.toMap()
}
private val statusByMoveId: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
when {
tags.any { it.startsWith("sleep_") } -> moveId to Statuses.SLEEP.showdownName
tags.any { it.startsWith("paralysis_") } -> moveId to Statuses.PARALYSIS.showdownName
tags.any { it.startsWith("burn_") } -> moveId to Statuses.BURN.showdownName
tags.any { it.startsWith("freeze_") } -> moveId to Statuses.FROZEN.showdownName
tags.any { it.startsWith("toxic_") } -> moveId to Statuses.POISON_BADLY.showdownName
tags.any { it.startsWith("poison_") } -> moveId to Statuses.POISON.showdownName
tags.any { it.startsWith("confusion_") } -> moveId to Statuses.CONFUSE.showdownName
tags.any { it.startsWith("attract_") } -> moveId to Statuses.ATTRACT.showdownName
"partiallytrapped_100" in tags -> moveId to "partiallytrapped"
"curse_100" in tags -> moveId to "cursed"
"leech_100" in tags -> moveId to "leech"
else -> null
}
}.toMap()
}
fun statusForMove(moveId: String): String? = statusByMoveId[moveId.lowercase()]
fun isStatusMove(moveId: String): Boolean {
val normalizedId = moveId.lowercase()
val tags = moveTags(normalizedId)
return "category_status" in tags || statusByMoveId.containsKey(normalizedId)
}
val boostFromMoves: Map<String, Map<Stat, Int>> by lazy {
val result = mutableMapOf<String, MutableMap<Stat, Int>>()
for (moveId in allMoveIds()) {
val tags = moveTags(moveId)
for (tag in tags) {
val match = statTagRegex.find(tag) ?: continue
val stat = when (match.groupValues[1]) {
"atk" -> Stats.ATTACK
"def" -> Stats.DEFENCE
"spatk" -> Stats.SPECIAL_ATTACK
"spdef" -> Stats.SPECIAL_DEFENCE
"speed" -> Stats.SPEED
"accuracy" -> Stats.ACCURACY
"evasion" -> Stats.EVASION
else -> continue
}
val stages = match.groupValues[2].toIntOrNull() ?: continue
result.computeIfAbsent(moveId) { mutableMapOf() }[stat] = stages
}
}
result
}
val weatherMoves: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val weatherTag = moveTags(moveId).firstOrNull { it.startsWith("sets_weather_") } ?: return@mapNotNull null
moveId to weatherTag.removePrefix("sets_weather_")
}.toMap()
}
val weatherDependentMoves: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
val requiredWeather = tags.firstOrNull { it.startsWith("context_weather_") }?.removePrefix("context_weather_")
if (requiredWeather != null) {
moveId to requiredWeather
} else if ("context_weather_sensitive" in tags) {
moveId to "any"
} else {
null
}
}.toMap()
}
// Kept as explicit fallback until ability tags add canonical weather-condition keys across all abilities.
val weatherAbilities: Map<String, String> = mapOf(
"swiftswim" to "rain",
"raindish" to "rain",
"dryskin" to "rain",
"chlorophyll" to "sun",
"leafguard" to "sun",
"flowergift" to "sun",
"solarpower" to "sun",
"sandrush" to "sand",
"sandveil" to "sand",
"sandforce" to "sand",
"icebody" to "hail",
"snowcloak" to "hail"
)
val entryHazards: List<String> by lazy {
allMoveIds().filter { hasAnyTag(it, setOf("hazard_set", "sets_side_condition")) }
}
val antiHazardsMoves: List<String> by lazy {
val tagged = allMoveIds().filter { hasAnyTag(it, setOf("hazard_clear", "removes_hazards")) }.toSet()
(tagged + knownAntiHazardsFallback).toList().filter { Moves.getByName(it) != null }
}
val antiBoostMoves: List<String> by lazy {
val tagged = allMoveIds().filter { hasAnyTag(it, setOf("clear_boosts", "anti_boost", "resets_stats")) }.toSet()
(tagged + knownAntiBoostFallback).toList().filter { Moves.getByName(it) != null }
}
val pivotMoves: List<String> by lazy {
allMoveIds().filter { "pivot" in moveTags(it) }
}
val setupMoves: Set<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"screen_set" in tags ||
"sets_terrain" in tags ||
"sets_weather" in tags ||
"sets_pseudo_weather" in tags ||
tags.any { it.startsWith("stat_") && !it.contains("_-") && "target_self" in tags }
}.toSet()
}
val selfRecoveryMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"healing_move" in tags && "target_self" in tags
}
}
val priorityMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"priority_positive" in tags || tags.any { it.startsWith("priority_") && (it.removePrefix("priority_").toIntOrNull() ?: 0) > 0 }
}
}
val recoilMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"recoil_move" in tags || tags.any { it.startsWith("recoil_") }
}
}
val selfKoMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
"self_ko" in moveTags(moveId)
}
}
val crashRiskMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
"crash_risk" in moveTags(moveId)
}
}
val highRiskMoves: List<String> by lazy {
(recoilMoves + selfKoMoves + crashRiskMoves).distinct()
}
}
@@ -0,0 +1,633 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.api.pokemon.status.Statuses
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.InBattleMove
import com.cobblemon.mod.common.battles.MoveTarget
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
import com.cobblemon.mod.common.pokemon.Pokemon
import kotlin.math.abs
import kotlin.math.max
class MoveEvaluator(
private val damageCalculator: DamageCalculator,
private val opponentPredictor: OpponentPredictor,
private val isDoublesMode: Boolean = false
) {
data class MoveEvaluation(
val move: InBattleMove,
val rawDamage: Double,
val expectedDamage: Double,
val strategicValue: Double,
val riskFactor: Double,
val priority: Int,
val turnAdvantage: Double,
val gamePlanAlignment: Double
) {
val totalScore: Double = (expectedDamage + strategicValue + gamePlanAlignment + turnAdvantage) / riskFactor
}
data class DebugDetails(
val hasStab: Boolean,
val effectiveness: Double,
val accuracy: Double,
val priority: Int,
val isStatusMove: Boolean,
val isSetup: Boolean,
val setsHazards: Boolean,
val removesHazards: Boolean,
val setsWeather: Boolean,
val isRecovery: Boolean,
val isPivot: Boolean,
val isRisky: Boolean
)
fun evaluateMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): MoveEvaluation {
val moveData = Moves.getByName(move.id)
?: return MoveEvaluation(move, 0.0, 0.0, 0.0, 1.0, 0, 0.0, 0.0)
val rawDamage = damageCalculator.calculateDamage(move, attacker, defender, battleState.weather)
val accuracy = getEffectiveAccuracy(move, attacker, defender, battleState.weather)
val expectedDamage = rawDamage * accuracy
val strategicValue = calculateStrategicValue(move, attacker, defender, battleState, gamePlan)
val riskFactor = calculateRiskFactor(move, attacker, defender, battleState, gamePlan)
val turnAdvantage = calculateTurnAdvantage(move, attacker, defender, battleState)
val gamePlanAlignment = calculateGamePlanAlignment(move, attacker, defender, battleState, gamePlan)
return MoveEvaluation(
move = move,
rawDamage = rawDamage,
expectedDamage = expectedDamage,
strategicValue = strategicValue,
riskFactor = riskFactor,
priority = moveData.priority,
turnAdvantage = turnAdvantage,
gamePlanAlignment = gamePlanAlignment
)
}
fun buildDebugDetails(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): DebugDetails {
val moveData = Moves.getByName(move.id)
?: return DebugDetails(
hasStab = false,
effectiveness = 1.0,
accuracy = 0.0,
priority = 0,
isStatusMove = false,
isSetup = false,
setsHazards = false,
removesHazards = false,
setsWeather = false,
isRecovery = false,
isPivot = false,
isRisky = false
)
val moveTags = BattleAITags.getMoveTags(move.id)
val attackerTypes = attacker.currentTypes ?: attacker.pokemon?.types?.map { it.name.lowercase() } ?: emptyList()
val effectiveness = TypeEffectiveness.calculateTypeEffectiveness(moveData.elementalType, defender.currentTypes ?: emptyList())
val priority = moveData.priority
val risk = calculateRiskFactor(move, attacker, defender, battleState, gamePlan)
return DebugDetails(
hasStab = AiBattleUtils.hasStab(moveData.elementalType.name, attackerTypes),
effectiveness = effectiveness,
accuracy = getEffectiveAccuracy(move, attacker, defender, battleState.weather),
priority = priority,
isStatusMove = MoveData.isStatusMove(move.id.lowercase()) || moveData.power <= 0,
isSetup = move.id.lowercase() in MoveData.boostFromMoves.keys,
setsHazards = move.id.lowercase() in MoveData.entryHazards || "hazard_set" in moveTags,
removesHazards = move.id.lowercase() in MoveData.antiHazardsMoves,
setsWeather = move.id.lowercase() in MoveData.weatherMoves.keys,
isRecovery = move.id.lowercase() in MoveData.selfRecoveryMoves,
isPivot = move.id.lowercase() in MoveData.pivotMoves,
isRisky = risk > 1.4
)
}
private fun getEffectiveAccuracy(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
weather: String?
): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
val moveTags = BattleAITags.getMoveTags(move.id)
var accuracy = moveData.accuracy / 100.0
val normalizedWeather = AiBattleUtils.normalizeWeatherId(weather)
if ("never_miss" in moveTags || "accuracy_true" in moveTags || "accuracy_perfect" in moveTags) {
accuracy = 1.0
}
when (normalizedWeather) {
"raindance" -> {
if ("context_weather_sensitive" in moveTags &&
("type_electric" in moveTags || "type_flying" in moveTags)
) {
accuracy = 1.0
}
}
"hail", "snow" -> {
if ("context_weather_sensitive" in moveTags && "type_ice" in moveTags) {
accuracy = 1.0
}
}
"sunnyday" -> {
if ("context_weather_sensitive" in moveTags &&
("type_electric" in moveTags || "type_flying" in moveTags)
) {
accuracy *= 0.5
}
}
}
when (attacker.currentAbility?.lowercase()) {
"compoundeyes" -> accuracy *= 1.3
"noguard" -> accuracy = 1.0
}
when (defender.currentAbility?.lowercase()) {
"noguard" -> accuracy = 1.0
"sandveil" -> if (normalizedWeather == "sandstorm") accuracy *= 0.8
"snowcloak" -> if (normalizedWeather in listOf("hail", "snow")) accuracy *= 0.8
}
val accuracyBoost = attacker.boosts.getOrDefault(Stats.ACCURACY, 0)
val evasionBoost = defender.boosts.getOrDefault(Stats.EVASION, 0)
val netBoost = accuracyBoost - evasionBoost
if (netBoost != 0) {
accuracy *= if (netBoost > 0) {
(3.0 + netBoost) / 3.0
} else {
3.0 / (3.0 - netBoost)
}
}
return accuracy.coerceIn(0.0, 1.0)
}
private fun calculateStrategicValue(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
var value = 0.0
val moveId = move.id.lowercase()
if (moveId in MoveData.weatherMoves.keys) {
value += evaluateWeatherSetting(move, gamePlan, battleState)
}
if (moveId in MoveData.boostFromMoves.keys) {
value += evaluateSetupMove(move, attacker, defender, battleState, gamePlan)
}
if (MoveData.statusForMove(moveId) != null) {
value += evaluateStatusMove(move, attacker, defender, battleState, gamePlan)
}
if (moveId in MoveData.entryHazards) {
value += evaluateHazardSetting(battleState, gamePlan)
}
if (moveId in MoveData.antiHazardsMoves) {
value += evaluateHazardRemoval(battleState, gamePlan)
}
val moveData = Moves.getByName(move.id)
if (moveData != null && moveData.priority > 0) {
value += evaluatePriorityMove(move, attacker, defender, battleState)
}
if (moveId in MoveData.selfRecoveryMoves) {
value += evaluateRecoveryMove(attacker, battleState, gamePlan)
}
if (moveId in MoveData.pivotMoves) {
value += evaluatePivotMove(move, attacker, defender, battleState)
}
if (battleState.gamePhase == GamePhase.ENDGAME) {
value += evaluateEndgameMove(move, battleState)
}
return value
}
private fun calculateGamePlanAlignment(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
val moveTags = BattleAITags.getMoveTags(move.id)
var alignment = 0.0
val moveId = move.id.lowercase()
val normalizedWeather = AiBattleUtils.normalizeWeatherId(battleState.weather)
if (moveId in gamePlan.riskyMoves || moveId in MoveData.highRiskMoves) {
alignment -= 100.0
}
alignment += when (gamePlan.winCondition) {
WinCondition.RAIN_OFFENSE -> {
var score = 0.0
if (MoveData.weatherMoves[moveId] == "rain") score += 200.0
if (normalizedWeather == "raindance") {
if ("type_water" in moveTags) score += 50.0
if ("context_weather_sensitive" in moveTags &&
("type_electric" in moveTags || "type_flying" in moveTags)
) {
score += 100.0
}
}
score
}
WinCondition.SUN_OFFENSE -> {
var score = 0.0
if (MoveData.weatherMoves[moveId] == "sun") score += 200.0
if (normalizedWeather == "sunnyday") {
if ("type_fire" in moveTags) score += 50.0
if ("context_weather_sensitive" in moveTags &&
("type_grass" in moveTags || "type_normal" in moveTags)
) {
score += 100.0
}
}
score
}
WinCondition.SETUP_SWEEP -> {
var score = 0.0
if (moveId in MoveData.boostFromMoves.keys && attacker.species in gamePlan.keyPokemon) {
score += 150.0
}
if (attacker.boosts.values.any { it >= 2 } && moveData.power > 80) {
score += 100.0
}
score
}
WinCondition.AGGRESSIVE_SWEEP -> {
var score = 0.0
if (moveData.power >= 90) score += 60.0
if (moveData.priority > 0 && defender.currentHpPercent < 0.5) score += 80.0
score
}
WinCondition.DEFENSIVE_STALL -> {
var score = 0.0
if (MoveData.isStatusMove(moveId)) score += 70.0
if (moveId in MoveData.selfRecoveryMoves) score += 60.0
if (moveData.power > 80) score -= 30.0
score
}
WinCondition.HAZARD_CONTROL -> {
var score = 0.0
if ("hazard_set" in moveTags && battleState.playerHazards.isEmpty()) {
score += 250.0
}
if ("hazard_set" in moveTags) score += 100.0
if ("hazard_clear" in moveTags || "removes_hazards" in moveTags) score += 120.0
score
}
WinCondition.REVENGE_KILLING -> {
var score = 0.0
if (moveData.priority > 0) score += 120.0
if (defender.currentHp > 50 && moveData.power in 40.0..80.0) score += 40.0
score
}
else -> 0.0
}
alignment += if (moveData.power > 0) {
moveData.power * gamePlan.aggressionLevel * 0.3
} else {
(1.0 - gamePlan.aggressionLevel) * 30.0
}
return alignment
}
private fun calculateRiskFactor(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
val moveData = Moves.getByName(move.id) ?: return 1.0
var risk = 1.0
val accuracy = getEffectiveAccuracy(move, attacker, defender, battleState.weather)
risk += (1.0 - accuracy) * 0.5
val moveTags = BattleAITags.getMoveTags(move.id)
if ("recoil_move" in moveTags || move.id.lowercase() in MoveData.recoilMoves) {
risk += if (attacker.currentHpPercent < 0.5) 0.5 else 0.2
}
if ("self_ko" in moveTags || move.id.lowercase() in MoveData.selfKoMoves) {
risk += when {
attacker.species in gamePlan.keyPokemon -> 2.0
attacker.currentHpPercent < 0.3 -> 0.2
else -> 0.8
}
}
if ("crash_risk" in moveTags) {
risk += if (attacker.currentHpPercent < 0.5) 0.4 else 0.2
}
if ("reliability_low" in moveTags) {
risk += 0.25
}
if ("delay_1" in moveTags && attacker.currentHpPercent < 0.5) {
risk += 0.2
}
if (move.pp <= 1) {
risk += 0.3
}
val mySpeed = attacker.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
val oppSpeed = defender.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
if (abs(mySpeed - oppSpeed) < 5) {
risk += 0.1
}
val teamAdvantage = calculateTeamAdvantage(battleState)
if (teamAdvantage > 0) {
risk *= 0.8
} else if (teamAdvantage < -1) {
risk *= 1.2
}
return max(0.1, risk)
}
private fun calculateTurnAdvantage(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState
): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
var advantage = 0.0
val mySpeed = attacker.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
val oppSpeed = defender.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
if (mySpeed > oppSpeed) advantage += 15.0
if (moveData.priority > 0) advantage += moveData.priority * 25.0
if (battleState.gamePhase == GamePhase.ENDGAME && moveData.power > 90) advantage += 20.0
return advantage
}
private fun evaluateWeatherSetting(move: InBattleMove, gamePlan: GamePlan, battleState: BattleState): Double {
val weatherType = MoveData.weatherMoves[move.id.lowercase()] ?: return 0.0
return when (gamePlan.winCondition) {
WinCondition.RAIN_OFFENSE -> if (weatherType == "rain") 300.0 else 0.0
WinCondition.SUN_OFFENSE -> if (weatherType == "sun") 300.0 else 0.0
WinCondition.SAND_OFFENSE -> if (weatherType == "sand") 300.0 else 0.0
WinCondition.WEATHER_CONTROL -> 150.0
else -> 30.0
}
}
private fun evaluateSetupMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
val boosts = MoveData.boostFromMoves[move.id.lowercase()] ?: return 0.0
var value = boosts.values.sum() * 40.0 * gamePlan.setupPriority
val relevantBoosts = boosts.keys.mapNotNull { stat -> attacker.boosts[stat] }
if (relevantBoosts.any { it >= 3 }) value *= 0.2
val predictedMoves = opponentPredictor.predictMoveHeuristically(defender, attacker)
val dangerLevel = predictedMoves.count { (moveName, _) -> (Moves.getByName(moveName)?.power ?: 0.0) > 80 }
if (dangerLevel > 0 && attacker.currentHpPercent < 0.7) {
value *= 0.4
}
return value
}
private fun evaluateStatusMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
if (defender.pokemon?.status != null) return 0.0
val status = MoveData.statusForMove(move.id.lowercase())
return when (status) {
Statuses.PARALYSIS.showdownName -> if (defender.predictedRole == PokemonRole.SWEEPER) 90.0 else 50.0
Statuses.BURN.showdownName -> if (defender.predictedRole in listOf(PokemonRole.WALLBREAKER, PokemonRole.SWEEPER)) 80.0 else 40.0
Statuses.POISON_BADLY.showdownName, Statuses.POISON.showdownName ->
if (gamePlan.winCondition == WinCondition.DEFENSIVE_STALL) 100.0 else 60.0
Statuses.SLEEP.showdownName -> 120.0
else -> 30.0
}
}
private fun evaluateHazardSetting(battleState: BattleState, gamePlan: GamePlan): Double {
val remainingOpponents = battleState.playerTeam.count { it.currentHealth > 0 }
if (remainingOpponents < 2) return 15.0
var value = remainingOpponents * 20.0
if (gamePlan.winCondition == WinCondition.HAZARD_CONTROL) value *= 1.8
return value
}
private fun evaluateHazardRemoval(battleState: BattleState, gamePlan: GamePlan): Double {
val myHazards = battleState.npcHazards.size
if (myHazards == 0) return 5.0
var value = myHazards * 40.0
val remainingTeam = battleState.npcTeam.count { it.currentHealth > 0 }
if (remainingTeam >= 3) value *= 1.3
return value
}
private fun evaluatePriorityMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState
): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
var value = moveData.priority * 20.0
if (defender.currentHpPercent < 0.5) {
val damage = damageCalculator.calculateDamage(move, attacker, defender, battleState.weather)
if (damage >= defender.currentHp) value += 150.0
}
val mySpeed = attacker.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
val oppSpeed = defender.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
if (abs(mySpeed - oppSpeed) < 10) value += 50.0
return value
}
private fun evaluateRecoveryMove(
attacker: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
if (attacker.currentHpPercent > 0.8) return 5.0
var value = (1.0 - attacker.currentHpPercent) * 140.0
if (gamePlan.winCondition == WinCondition.DEFENSIVE_STALL) value *= 1.5
return value
}
private fun evaluatePivotMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState
): Double {
var value = 40.0
val damage = damageCalculator.calculateDamage(move, attacker, defender, battleState.weather)
value += damage * 0.3
return value
}
private fun evaluateEndgameMove(move: InBattleMove, battleState: BattleState): Double {
val moveData = Moves.getByName(move.id) ?: return 0.0
var bonus = 0.0
if (moveData.power > 0 && moveData.accuracy >= 90) bonus += 35.0
if (moveData.accuracy < 80) bonus -= 30.0
if (moveData.priority > 0) bonus += 50.0
return bonus
}
private fun calculateTeamAdvantage(battleState: BattleState): Int {
val npcAlive = battleState.npcTeam.count { it.currentHealth > 0 }
val playerAlive = battleState.playerTeam.count { it.currentHealth > 0 }
return npcAlive - playerAlive
}
private fun getEffectiveStat(pokemon: Pokemon, stat: Stats): Double {
val base = pokemon.species.baseStats[stat] ?: 0
val iv = pokemon.ivs[stat] ?: 31
val ev = pokemon.evs[stat] ?: 0
val level = pokemon.level
return if (stat == Stats.HP) {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + level + 10.0)
} else {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + 5.0)
}
}
fun evaluateMoveForDoubles(
move: InBattleMove,
attacker: TrackedPokemon,
primaryTarget: TrackedPokemon,
allOpponents: List<TrackedPokemon>,
partner: TrackedPokemon?,
battleState: BattleState,
gamePlan: GamePlan
): MoveEvaluation {
val moveData = Moves.getByName(move.id)
?: return MoveEvaluation(move, 0.0, 0.0, 0.0, 1.0, 0, 0.0, 0.0)
if (isSpreadMove(moveData.target)) {
return evaluateSpreadMove(move, attacker, allOpponents, partner, battleState, gamePlan)
}
val baseEvaluation = evaluateMove(move, attacker, primaryTarget, battleState, gamePlan)
val targetingBonus = if (primaryTarget.threatLevel > 5) 20.0 else 0.0
return MoveEvaluation(
move = baseEvaluation.move,
rawDamage = baseEvaluation.rawDamage,
expectedDamage = baseEvaluation.expectedDamage,
strategicValue = baseEvaluation.strategicValue + targetingBonus,
riskFactor = baseEvaluation.riskFactor,
priority = baseEvaluation.priority,
turnAdvantage = baseEvaluation.turnAdvantage,
gamePlanAlignment = baseEvaluation.gamePlanAlignment
)
}
private fun evaluateSpreadMove(
move: InBattleMove,
attacker: TrackedPokemon,
allOpponents: List<TrackedPokemon>,
partner: TrackedPokemon?,
battleState: BattleState,
gamePlan: GamePlan
): MoveEvaluation {
val moveData = Moves.getByName(move.id)
?: return MoveEvaluation(move, 0.0, 0.0, 0.0, 1.0, 0, 0.0, 0.0)
var totalDamage = 0.0
var totalStrategicValue = 0.0
val activeOpponents = allOpponents.filter { it.currentHp > 0 }
val targetCount = activeOpponents.size + (if (hitsPartner(moveData.target) && partner != null) 1 else 0)
for (opponent in activeOpponents) {
val damage = damageCalculator.calculateDamage(
move,
attacker,
opponent,
battleState.weather,
isSpreadMove = true,
targetCount = targetCount
)
totalDamage += damage
if (damage >= opponent.currentHp) {
totalStrategicValue += 150.0
}
}
totalStrategicValue += activeOpponents.size * 40.0
var partnerPenalty = 0.0
if (hitsPartner(moveData.target) && partner != null && partner.currentHp > 0) {
val partnerDamage = damageCalculator.calculateDamage(
move,
attacker,
partner,
battleState.weather,
isSpreadMove = true,
targetCount = targetCount
)
partnerPenalty = when {
partnerDamage >= partner.currentHp -> -999999.0
partner.species in gamePlan.keyPokemon -> -partnerDamage * 5.0
partner.currentHpPercent < 0.3 -> -partnerDamage * 4.0
else -> -partnerDamage * 2.0
}
totalStrategicValue += partnerPenalty
}
val accuracy = getEffectiveAccuracy(move, attacker, activeOpponents.firstOrNull() ?: TrackedPokemon(), battleState.weather)
val expectedDamage = totalDamage * accuracy
val riskFactor = when {
partnerPenalty < -1000 -> 999.0
partnerPenalty < 0 -> max(1.5, 1.0 + abs(partnerPenalty) / 100.0)
else -> 1.0
}
val gamePlanAlignment = activeOpponents.firstOrNull()?.let {
calculateGamePlanAlignment(move, attacker, it, battleState, gamePlan)
} ?: 0.0
return MoveEvaluation(
move = move,
rawDamage = totalDamage,
expectedDamage = expectedDamage,
strategicValue = totalStrategicValue,
riskFactor = riskFactor,
priority = moveData.priority,
turnAdvantage = 0.0,
gamePlanAlignment = gamePlanAlignment
)
}
private fun isSpreadMove(target: MoveTarget): Boolean {
return target in listOf(MoveTarget.allAdjacent, MoveTarget.allAdjacentFoes, MoveTarget.all)
}
private fun hitsPartner(target: MoveTarget): Boolean {
return target == MoveTarget.allAdjacent
}
}
@@ -0,0 +1,32 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
object NewAiBattleUtils {
private val percentSuffixRegex = Regex("_(\\d+)$")
fun normalizeWeatherId(weather: String?): String? = AiBattleUtils.normalizeWeatherId(weather)
fun hasStab(moveTypeName: String, pokemonTypes: List<String>): Boolean =
AiBattleUtils.hasStab(moveTypeName, pokemonTypes)
fun moveTags(moveId: String?): Set<String> = BattleAITags.getMoveTags(moveId)
fun abilityTags(abilityId: String?): Set<String> = BattleAITags.getAbilityTags(abilityId)
fun parseTagPercent(tag: String, prefix: String): Int? = AiBattleUtils.parseTagPercent(tag, prefix)
fun parseTrailingPercent(tag: String): Int? =
percentSuffixRegex.find(tag)?.groupValues?.getOrNull(1)?.toIntOrNull()
fun hasTag(tags: Set<String>, tag: String): Boolean = tag.lowercase() in tags
fun hasTagPrefix(tags: Set<String>, prefix: String): Boolean =
tags.any { it.startsWith(prefix.lowercase()) }
fun anyTag(tags: Set<String>, candidates: Set<String>): Boolean =
candidates.any { it in tags }
fun firstTagValue(tags: Set<String>, prefix: String): String? =
tags.firstOrNull { it.startsWith(prefix) }?.removePrefix(prefix)
}
@@ -0,0 +1,44 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
typealias NewBattleState = BattleState
typealias NewTrackedPokemon = TrackedPokemon
typealias NewTeamTracker = TeamTracker
typealias NewGamePhase = GamePhase
typealias NewPokemonRole = PokemonRole
typealias NewBattleSituation = BattleSituation
typealias NewMatchupEvaluation = MatchupEvaluation
typealias NewMatchupRecommendation = MatchupRecommendation
class NewEnhancedActiveTracker {
private val delegate = EnhancedActiveTracker()
var p1Active: TeamTracker
get() = delegate.p1Active
set(value) {
delegate.p1Active = value
}
var p2Active: TeamTracker
get() = delegate.p2Active
set(value) {
delegate.p2Active = value
}
fun updatePokemonSlot(team: TeamTracker, slotIndex: Int, battlePokemon: BattlePokemon) {
delegate.updatePokemonSlot(team, slotIndex, battlePokemon)
}
fun getTrackedPokemonByPNX(pnx: String, team: TeamTracker): TrackedPokemon? {
return delegate.getTrackedPokemonByPNX(pnx, team)
}
fun updatePokemon(tracker: TrackedPokemon, battlePokemon: BattlePokemon) {
delegate.updatePokemon(tracker, battlePokemon)
}
fun updateParty(team: TeamTracker, pokemon: TrackedPokemon) {
delegate.updateParty(team, pokemon)
}
}
@@ -0,0 +1,49 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.battles.InBattleMove
class NewDamageCalculator(battle: PokemonBattle?) {
internal val delegate = DamageCalculator(battle)
fun calculateDamage(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
weather: String? = null,
criticalHit: Boolean = false,
isSpreadMove: Boolean = false,
targetCount: Int = 1
): Double {
val base = delegate.calculateDamage(move, attacker, defender, weather, criticalHit, isSpreadMove, targetCount)
if (base <= 0.0) {
return 0.0
}
val moveData = Moves.getByName(move.id) ?: return base
val moveTags = NewAiBattleUtils.moveTags(move.id)
var adjusted = base
if ("delay_1" in moveTags) {
adjusted *= 0.92
}
if ("recharge_turn" in moveTags) {
adjusted *= 0.87
}
if ("ohko" in moveTags) {
adjusted *= 0.45
}
if ("context_weather_sensitive" in moveTags) {
val normalizedWeather = NewAiBattleUtils.normalizeWeatherId(weather)
if (normalizedWeather == null) {
adjusted *= 0.95
}
}
if (moveData.power <= 0 && moveTags.any { it.startsWith("stat_") }) {
adjusted *= 0.1
}
return adjusted
}
}
@@ -0,0 +1,101 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.battles.ActiveBattlePokemon
import com.cobblemon.mod.common.battles.InBattleMove
import com.cobblemon.mod.common.battles.ShowdownMoveset
class NewDoublesDecisionMaker(
private val moveEvaluator: NewMoveEvaluator,
private val damageCalculator: NewDamageCalculator
) {
data class MoveTargetPair(
val move: InBattleMove,
val targetPNX: String?,
val evaluation: NewMoveEvaluator.MoveEvaluation
)
fun findBestMoveAndTarget(
moveset: ShowdownMoveset?,
activeBattlePokemon: ActiveBattlePokemon,
myPokemon: TrackedPokemon,
opponents: List<TrackedPokemon>,
partner: TrackedPokemon?,
battleState: BattleState,
gamePlan: GamePlan
): MoveTargetPair? {
if (moveset == null) return null
val usableMoves = moveset.moves.filter { it.canBeUsed() }
if (usableMoves.isEmpty()) return null
val allOptions = mutableListOf<MoveTargetPair>()
for (move in usableMoves) {
Moves.getByName(move.id) ?: continue
val targetList = move.target.targetList(activeBattlePokemon)
if (targetList == null) {
val evaluation = moveEvaluator.evaluateMoveForDoubles(
move,
myPokemon,
opponents.firstOrNull() ?: TrackedPokemon(),
opponents,
partner,
battleState,
gamePlan
)
allOptions.add(MoveTargetPair(move, null, evaluation))
continue
}
val opponentTargets = targetList.filterIsInstance<ActiveBattlePokemon>()
.filter { !it.isAllied(activeBattlePokemon) }
for (target in opponentTargets) {
val targetPokemon = opponents.find { it.pokemon?.uuid == target.battlePokemon?.uuid } ?: continue
val evaluation = moveEvaluator.evaluateMoveForDoubles(
move,
myPokemon,
targetPokemon,
opponents,
partner,
battleState,
gamePlan
)
allOptions.add(MoveTargetPair(move, target.getPNX(), evaluation))
}
}
return allOptions.maxByOrNull { it.evaluation.totalScore }
}
fun selectBestTarget(
move: InBattleMove,
activeBattlePokemon: ActiveBattlePokemon,
myPokemon: TrackedPokemon,
opponents: List<TrackedPokemon>,
battleState: BattleState
): String? {
val targetList = move.target.targetList(activeBattlePokemon) ?: return null
val opponentTargets = targetList.filterIsInstance<ActiveBattlePokemon>()
.filter { !it.isAllied(activeBattlePokemon) }
if (opponentTargets.isEmpty()) return null
val targetScores = opponentTargets.mapNotNull { target ->
val targetPokemon = opponents.find { it.pokemon?.uuid == target.battlePokemon?.uuid } ?: return@mapNotNull null
val damage = damageCalculator.calculateDamage(move, myPokemon, targetPokemon, battleState.weather)
target to (damage + scoreTarget(targetPokemon, myPokemon))
}
return targetScores.maxByOrNull { it.second }?.first?.getPNX()
}
private fun scoreTarget(target: TrackedPokemon, myPokemon: TrackedPokemon): Double {
var score = target.threatLevel * 20.0
if (target.cachedSpeed > myPokemon.cachedSpeed && target.currentHpPercent < 0.5) {
score += 80.0
}
if (target.boosts.values.any { it >= 2 }) {
score += 100.0
}
if (target.currentHpPercent < 0.35) {
score += 50.0
}
return score
}
}
@@ -0,0 +1,206 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.abilities.Abilities
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.api.pokemon.status.Statuses
object NewMoveData {
private val statTagRegex = Regex("^stat_(atk|def|spatk|spdef|speed|accuracy|evasion)_(-?\\d+)(?:_(\\d+))?$")
private val knownAntiHazardsFallback = listOf("rapidspin", "defog", "tidyup", "courtchange", "mortalspin")
private val knownAntiBoostFallback = listOf("clearsmog", "haze", "topsyturvy", "roar", "whirlwind")
private val knownWeatherAbilityFallback = mapOf(
"swiftswim" to "rain",
"raindish" to "rain",
"dryskin" to "rain",
"chlorophyll" to "sun",
"leafguard" to "sun",
"flowergift" to "sun",
"solarpower" to "sun",
"sandrush" to "sand",
"sandveil" to "sand",
"sandforce" to "sand",
"icebody" to "hail",
"snowcloak" to "snow"
)
private fun moveTags(moveId: String): Set<String> = NewAiBattleUtils.moveTags(moveId)
private fun allMoveIds(): List<String> = Moves.all().map { it.name.lowercase() }
val multiHitMoves: Map<String, Map<Int, Int>> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
val min = tags.firstOrNull { it.startsWith("multi_hit_min_") }?.removePrefix("multi_hit_min_")?.toIntOrNull()
val max = tags.firstOrNull { it.startsWith("multi_hit_max_") }?.removePrefix("multi_hit_max_")?.toIntOrNull()
when {
min != null && max != null -> moveId to mapOf(min to max)
"multi_hit" in tags -> moveId to mapOf(2 to 5)
else -> null
}
}.toMap()
}
private val statusByMoveId: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
when {
tags.any { it.startsWith("sleep_") } -> moveId to Statuses.SLEEP.showdownName
tags.any { it.startsWith("paralysis_") } -> moveId to Statuses.PARALYSIS.showdownName
tags.any { it.startsWith("burn_") } -> moveId to Statuses.BURN.showdownName
tags.any { it.startsWith("freeze_") } -> moveId to Statuses.FROZEN.showdownName
tags.any { it.startsWith("toxic_") } -> moveId to Statuses.POISON_BADLY.showdownName
tags.any { it.startsWith("poison_") } -> moveId to Statuses.POISON.showdownName
tags.any { it.startsWith("confusion_") } -> moveId to Statuses.CONFUSE.showdownName
tags.any { it.startsWith("attract_") } -> moveId to Statuses.ATTRACT.showdownName
"partiallytrapped_100" in tags -> moveId to "partiallytrapped"
"curse_100" in tags -> moveId to "cursed"
"leechseed_100" in tags -> moveId to "leech"
else -> null
}
}.toMap()
}
fun statusForMove(moveId: String): String? = statusByMoveId[moveId.lowercase()]
fun isStatusMove(moveId: String): Boolean {
val normalized = moveId.lowercase()
val tags = moveTags(normalized)
return "category_status" in tags || statusByMoveId.containsKey(normalized)
}
val boostFromMoves: Map<String, Map<Stat, Int>> by lazy {
val result = mutableMapOf<String, MutableMap<Stat, Int>>()
for (moveId in allMoveIds()) {
val tags = moveTags(moveId)
for (tag in tags) {
val match = statTagRegex.find(tag) ?: continue
val stat = when (match.groupValues[1]) {
"atk" -> Stats.ATTACK
"def" -> Stats.DEFENCE
"spatk" -> Stats.SPECIAL_ATTACK
"spdef" -> Stats.SPECIAL_DEFENCE
"speed" -> Stats.SPEED
"accuracy" -> Stats.ACCURACY
"evasion" -> Stats.EVASION
else -> continue
}
val stages = match.groupValues[2].toIntOrNull() ?: continue
result.computeIfAbsent(moveId) { mutableMapOf() }[stat] = stages
}
}
result
}
val weatherMoves: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val weatherTag = moveTags(moveId).firstOrNull { it.startsWith("sets_weather_") } ?: return@mapNotNull null
moveId to weatherTag.removePrefix("sets_weather_")
}.toMap()
}
val weatherDependentMoves: Map<String, String> by lazy {
allMoveIds().mapNotNull { moveId ->
val tags = moveTags(moveId)
val required = tags.firstOrNull { it.startsWith("context_weather_") }?.removePrefix("context_weather_")
when {
required != null -> moveId to required
"context_weather_sensitive" in tags -> moveId to "any"
else -> null
}
}.toMap()
}
val weatherAbilities: Map<String, String> by lazy {
val fromTags = Abilities.all()
.map { it.name.lowercase() }
.mapNotNull { abilityId ->
val abilityTags = NewAiBattleUtils.abilityTags(abilityId)
if (
"weather_setter" !in abilityTags &&
"weather_interaction" !in abilityTags &&
"self_chip_weather" !in abilityTags
) {
return@mapNotNull null
}
val weather = abilityTags.firstOrNull { it.startsWith("conditional_weather_") }
?.removePrefix("conditional_weather_")
?: "any"
abilityId to weather
}
.toMap()
knownWeatherAbilityFallback + fromTags
}
val entryHazards: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"hazard_set" in tags || tags.any { it.startsWith("sets_side_condition_") && it !in setOf("sets_side_condition_mist", "sets_side_condition_safeguard") }
}
}
val antiHazardsMoves: List<String> by lazy {
val tagged = allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"hazard_clear" in tags || "removes_hazards" in tags || "context_anti_hazard" in tags
}.toSet()
(tagged + knownAntiHazardsFallback).toList().filter { Moves.getByName(it) != null }
}
val antiBoostMoves: List<String> by lazy {
val tagged = allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"clear_boosts" in tags || "anti_boost" in tags || "resets_stats" in tags || "phaze" in tags
}.toSet()
(tagged + knownAntiBoostFallback).toList().filter { Moves.getByName(it) != null }
}
val pivotMoves: List<String> by lazy {
allMoveIds().filter { "pivot" in moveTags(it) }
}
val setupMoves: Set<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"screen_set" in tags ||
"sets_terrain" in tags ||
"sets_weather" in tags ||
"sets_pseudo_weather" in tags ||
tags.any { it.startsWith("stat_") && !it.contains("_-") && "target_self" in tags }
}.toSet()
}
val selfRecoveryMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
tags.any { it.startsWith("heal_") } && "target_self" in tags
}
}
val priorityMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
moveTags(moveId).any { it.startsWith("priority_") && (it.removePrefix("priority_").toIntOrNull() ?: 0) > 0 }
}
}
val recoilMoves: List<String> by lazy {
allMoveIds().filter { moveId ->
val tags = moveTags(moveId)
"recoil_move" in tags || tags.any { it.startsWith("recoil_") }
}
}
val selfKoMoves: List<String> by lazy {
allMoveIds().filter { "self_ko" in moveTags(it) }
}
val crashRiskMoves: List<String> by lazy {
allMoveIds().filter { "crash_risk" in moveTags(it) }
}
val highRiskMoves: List<String> by lazy {
(recoilMoves + selfKoMoves + crashRiskMoves).distinct()
}
}
@@ -0,0 +1,324 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import com.cobblemon.mod.common.battles.InBattleMove
import kotlin.math.max
class NewMoveEvaluator(
private val damageCalculator: NewDamageCalculator,
private val opponentPredictor: NewOpponentPredictor,
private val isDoublesMode: Boolean = false
) {
private val delegate = MoveEvaluator(damageCalculator.delegate, opponentPredictor.delegate, isDoublesMode)
private val priorityRegex = Regex("^priority_(-?\\d+)$")
private val healRegex = Regex("^heal_(\\d+)$")
private val chanceRegex = Regex("_(\\d+)$")
private val statRegex = Regex("^stat_(atk|def|spatk|spdef|speed|accuracy|evasion)_(-?\\d+)(?:_(\\d+))?$")
private val recoilRegex = Regex("^recoil_(\\d+)$")
data class MoveEvaluation(
val move: InBattleMove,
val rawDamage: Double,
val expectedDamage: Double,
val strategicValue: Double,
val riskFactor: Double,
val priority: Int,
val turnAdvantage: Double,
val gamePlanAlignment: Double
) {
val totalScore: Double = (expectedDamage + strategicValue + gamePlanAlignment + turnAdvantage) / riskFactor
}
data class DebugDetails(
val hasStab: Boolean,
val effectiveness: Double,
val accuracy: Double,
val priority: Int,
val isStatusMove: Boolean,
val isSetup: Boolean,
val setsHazards: Boolean,
val removesHazards: Boolean,
val setsWeather: Boolean,
val isRecovery: Boolean,
val isPivot: Boolean,
val isRisky: Boolean
)
fun evaluateMove(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): MoveEvaluation {
val base = delegate.evaluateMove(move, attacker, defender, battleState, gamePlan)
val moveTags = NewAiBattleUtils.moveTags(move.id)
val attackerAbility = attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
val attackerAbilityTags = NewAiBattleUtils.abilityTags(attackerAbility)
val defenderAbilityTags = NewAiBattleUtils.abilityTags(defender.currentAbility?.lowercase() ?: defender.pokemon?.ability?.name?.lowercase())
val utilityBonus = tagUtilityBonus(
moveTags = moveTags,
attacker = attacker,
defender = defender,
battleState = battleState,
attackerAbilityTags = attackerAbilityTags,
defenderAbilityTags = defenderAbilityTags
)
val riskPenalty = tagRiskPenalty(moveTags, attacker, battleState)
val expectedDamage = applyTagExpectedDamageAdjustments(base.expectedDamage, moveTags, attackerAbility)
val gamePlanBonus = tagGamePlanBonus(moveTags, gamePlan, battleState)
return MoveEvaluation(
move = base.move,
rawDamage = base.rawDamage,
expectedDamage = expectedDamage,
strategicValue = base.strategicValue + utilityBonus,
riskFactor = max(0.1, base.riskFactor + riskPenalty),
priority = max(base.priority, parsePriority(moveTags, base.priority)),
turnAdvantage = base.turnAdvantage,
gamePlanAlignment = base.gamePlanAlignment + gamePlanBonus
)
}
fun evaluateMoveForDoubles(
move: InBattleMove,
attacker: TrackedPokemon,
primaryTarget: TrackedPokemon,
allOpponents: List<TrackedPokemon>,
partner: TrackedPokemon?,
battleState: BattleState,
gamePlan: GamePlan
): MoveEvaluation {
val base = delegate.evaluateMoveForDoubles(
move,
attacker,
primaryTarget,
allOpponents,
partner,
battleState,
gamePlan
)
val moveTags = NewAiBattleUtils.moveTags(move.id)
val attackerAbility = attacker.currentAbility?.lowercase() ?: attacker.pokemon?.ability?.name?.lowercase()
val attackerAbilityTags = NewAiBattleUtils.abilityTags(attackerAbility)
var strategic = base.strategicValue
var risk = base.riskFactor
if ("target_opponents" in moveTags || "target_all" in moveTags) {
strategic += allOpponents.count { it.currentHp > 0 } * 10.0
}
if ("target_all" in moveTags && partner != null && partner.currentHpPercent < 0.4) {
risk += 0.35
}
if ("pivot" in moveTags) {
strategic += 12.0
}
if ("move_tag_damage_synergy" in attackerAbilityTags && moveTags.any { it in TAG_SYNERGY_KEYS }) {
strategic += 14.0
}
return MoveEvaluation(
move = base.move,
rawDamage = base.rawDamage,
expectedDamage = applyTagExpectedDamageAdjustments(base.expectedDamage, moveTags, attackerAbility),
strategicValue = strategic,
riskFactor = max(0.1, risk + tagRiskPenalty(moveTags, attacker, battleState)),
priority = max(base.priority, parsePriority(moveTags, base.priority)),
turnAdvantage = base.turnAdvantage,
gamePlanAlignment = base.gamePlanAlignment + tagGamePlanBonus(moveTags, gamePlan, battleState)
)
}
fun buildDebugDetails(
move: InBattleMove,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): DebugDetails {
val details = delegate.buildDebugDetails(move, attacker, defender, battleState, gamePlan)
return DebugDetails(
hasStab = details.hasStab,
effectiveness = details.effectiveness,
accuracy = details.accuracy,
priority = details.priority,
isStatusMove = details.isStatusMove,
isSetup = details.isSetup,
setsHazards = details.setsHazards,
removesHazards = details.removesHazards,
setsWeather = details.setsWeather,
isRecovery = details.isRecovery,
isPivot = details.isPivot,
isRisky = details.isRisky
)
}
private fun applyTagExpectedDamageAdjustments(
expectedDamage: Double,
moveTags: Set<String>,
attackerAbilityId: String?
): Double {
var adjusted = expectedDamage
if (adjusted <= 0.0) {
return adjusted
}
if ("move_tag_damage_synergy" in NewAiBattleUtils.abilityTags(attackerAbilityId) &&
moveTags.any { it in TAG_SYNERGY_KEYS }
) {
adjusted *= 1.1
}
if ("delay_1" in moveTags) adjusted *= 0.9
if ("recharge_turn" in moveTags) adjusted *= 0.85
return adjusted
}
private fun tagUtilityBonus(
moveTags: Set<String>,
attacker: TrackedPokemon,
defender: TrackedPokemon,
battleState: BattleState,
attackerAbilityTags: Set<String>,
defenderAbilityTags: Set<String>
): Double {
var bonus = 0.0
if (moveTags.any { it.startsWith("heal_") } || "heal_variable" in moveTags) {
val heal = parseHealPercent(moveTags).coerceAtLeast(25)
bonus += (1.0 - attacker.currentHpPercent).coerceAtLeast(0.0) * heal
if (attacker.currentHpPercent <= 0.35) bonus += 24.0
if (attacker.currentHpPercent >= 0.9) bonus -= 10.0
}
if ("target_self" in moveTags) {
val setupGain = moveTags.mapNotNull { statRegex.find(it)?.groupValues?.getOrNull(2)?.toIntOrNull() }
.filter { it > 0 }
.sum()
bonus += setupGain * 12.0
if (battleState.gamePhase == GamePhase.EARLY_GAME || battleState.gamePhase == GamePhase.MID_GAME) {
bonus += setupGain * 8.0
}
}
val statusChance = parseStatusChance(moveTags)
if (statusChance > 0 && defender.pokemon?.status == null && "status_move_immunity" !in defenderAbilityTags) {
bonus += statusChance * 0.35
}
if ("hazard_set" in moveTags && battleState.playerHazards.isEmpty()) {
bonus += 40.0
}
if (("hazard_clear" in moveTags || "removes_hazards" in moveTags) && battleState.npcHazards.isNotEmpty()) {
bonus += 35.0 + battleState.npcHazards.size * 8.0
}
val priority = parsePriority(moveTags, 0)
if (priority > 0 && attacker.currentHpPercent < 0.5) {
bonus += 10.0 + (priority * 6.0)
}
if ("protect_like" in moveTags || "invincible" in moveTags) {
if (attacker.currentHpPercent < 0.45) bonus += 20.0
if (battleState.gamePhase == GamePhase.ENDGAME) bonus += 8.0
}
if ("pivot" in moveTags) {
bonus += if (attacker.currentHpPercent < 0.4) 20.0 else 8.0
}
if ("sets_weather" in moveTags && attackerAbilityTags.any { it == "weather_interaction" || it.startsWith("conditional_weather_") }) {
bonus += 22.0
}
if ("sets_terrain" in moveTags && attackerAbilityTags.any { it == "terrain_interaction" || it.startsWith("type_") }) {
bonus += 12.0
}
if ("move_tag_damage_synergy" in attackerAbilityTags && moveTags.any { it in TAG_SYNERGY_KEYS }) {
bonus += 14.0
}
if ("secondary_chance_boost" in attackerAbilityTags && moveTags.any { it.startsWith("secondary_chance_") }) {
bonus += 10.0
}
if ("status_move_immunity" in attackerAbilityTags && "category_status" in moveTags) {
bonus += 6.0
}
return bonus
}
private fun tagGamePlanBonus(moveTags: Set<String>, gamePlan: GamePlan, battleState: BattleState): Double {
var bonus = 0.0
when (gamePlan.winCondition) {
WinCondition.HAZARD_CONTROL -> {
if ("hazard_set" in moveTags) bonus += 40.0
if ("hazard_clear" in moveTags || "removes_hazards" in moveTags) bonus += 35.0
}
WinCondition.RAIN_OFFENSE, WinCondition.SUN_OFFENSE, WinCondition.SAND_OFFENSE, WinCondition.WEATHER_CONTROL -> {
if ("sets_weather" in moveTags) bonus += 32.0
if ("context_weather_sensitive" in moveTags && battleState.weather != null) bonus += 14.0
}
WinCondition.SETUP_SWEEP -> {
if ("target_self" in moveTags && moveTags.any { it.startsWith("stat_") && !it.contains("_-") }) bonus += 28.0
}
WinCondition.DEFENSIVE_STALL -> {
if (moveTags.any { it.startsWith("heal_") } || "heal_variable" in moveTags) bonus += 20.0
if (moveTags.any { it.startsWith("toxic_") || it.startsWith("poison_") }) bonus += 16.0
}
else -> {}
}
return bonus
}
private fun tagRiskPenalty(moveTags: Set<String>, attacker: TrackedPokemon, battleState: BattleState): Double {
var penalty = 0.0
val hpRiskFactor = (1.0 - attacker.currentHpPercent).coerceAtLeast(0.1)
if ("self_ko" in moveTags) penalty += 1.6
if ("crash_risk" in moveTags) penalty += 0.35
if ("recharge_turn" in moveTags) penalty += 0.45
if ("delay_1" in moveTags && attacker.currentHpPercent < 0.55) penalty += 0.2
moveTags.forEach {
val recoil = recoilRegex.find(it)?.groupValues?.getOrNull(1)?.toIntOrNull()
if (recoil != null) {
penalty += (recoil / 100.0) * (0.8 + hpRiskFactor)
}
}
val minAccuracy = moveTags
.filter { it.startsWith("accuracy_") && it != "accuracy_true" }
.mapNotNull { it.removePrefix("accuracy_").toIntOrNull() }
.minOrNull()
if (minAccuracy != null && minAccuracy < 90) {
penalty += ((90 - minAccuracy) / 100.0)
}
if (battleState.gamePhase == GamePhase.ENDGAME && "self_ko" in moveTags) {
penalty += 0.8
}
return penalty
}
private fun parsePriority(tags: Set<String>, fallback: Int): Int {
return tags.mapNotNull { priorityRegex.find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() }.firstOrNull() ?: fallback
}
private fun parseHealPercent(tags: Set<String>): Int {
return tags.mapNotNull { healRegex.find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() }.maxOrNull() ?: 0
}
private fun parseStatusChance(tags: Set<String>): Int {
return tags.filter {
it.startsWith("burn_") ||
it.startsWith("paralysis_") ||
it.startsWith("sleep_") ||
it.startsWith("poison_") ||
it.startsWith("toxic_") ||
it.startsWith("freeze_") ||
it.startsWith("confusion_")
}.mapNotNull { chanceRegex.find(it)?.groupValues?.getOrNull(1)?.toIntOrNull() }.maxOrNull() ?: 0
}
companion object {
private val TAG_SYNERGY_KEYS = setOf("biting", "punching", "sound", "bullet", "wind", "pulse", "dance")
}
}
@@ -0,0 +1,37 @@
package com.cobblemon.mod.common.battles.ai.competitive
class NewOpponentPredictor {
internal val delegate = OpponentPredictor()
fun updateMoveHistory(pokemon: String, move: String, situation: String) {
delegate.updateMoveHistory(pokemon, move, situation)
}
fun updateSwitchPattern(fromPokemon: String, toPokemon: String, reason: String) {
delegate.updateSwitchPattern(fromPokemon, toPokemon, reason)
}
fun predictNextMove(pokemon: String, situation: String): Map<String, Double> {
return delegate.predictNextMove(pokemon, situation)
}
fun getSwitchProbability(pokemon: String, situation: String): Double {
return delegate.getSwitchProbability(pokemon, situation)
}
fun predictMoveHeuristically(opponent: TrackedPokemon, myPokemon: TrackedPokemon): Map<String, Double> {
return delegate.predictMoveHeuristically(opponent, myPokemon)
}
fun predictSwitchTarget(fromPokemon: String, myActivePokemon: TrackedPokemon): Map<String, Double> {
return delegate.predictSwitchTarget(fromPokemon, myActivePokemon)
}
fun categorizePlaystyle(pokemon: String): OpponentPlaystyle {
return delegate.categorizePlaystyle(pokemon)
}
fun reset() {
delegate.reset()
}
}
@@ -0,0 +1,15 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.pokemon.Pokemon
class NewStrategyAnalyzer {
internal val delegate = StrategyAnalyzer()
fun analyzeTeamComposition(team: List<Pokemon>): GamePlan {
return delegate.analyzeTeamComposition(team)
}
fun identifyPokemonRole(pokemon: Pokemon): PokemonRole {
return delegate.identifyPokemonRole(pokemon)
}
}
@@ -0,0 +1,221 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Move
import com.cobblemon.mod.common.api.pokemon.stats.Stat
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.pokemon.Pokemon
class NewSwitchDecisionMaker(
private val damageCalculator: NewDamageCalculator,
private val opponentPredictor: NewOpponentPredictor,
private val strategyAnalyzer: NewStrategyAnalyzer
) {
private val delegate = SwitchDecisionMaker(
damageCalculator.delegate,
opponentPredictor.delegate,
strategyAnalyzer.delegate
)
data class SwitchDecisionDiagnostics(
val shouldSwitch: Boolean,
val reason: String,
val currentScore: Double,
val bestScore: Double,
val scoreGain: Double,
val severeDisadvantage: Boolean,
val strongCounterFound: Boolean,
val currentThreat: Double,
val bestThreat: Double,
val currentPressure: Double,
val bestPressure: Double,
val bestSwitchName: String?
)
private var lastDiagnostics = SwitchDecisionDiagnostics(
shouldSwitch = false,
reason = "No decision computed yet.",
currentScore = 0.0,
bestScore = 0.0,
scoreGain = 0.0,
severeDisadvantage = false,
strongCounterFound = false,
currentThreat = 1.0,
bestThreat = 1.0,
currentPressure = 1.0,
bestPressure = 1.0,
bestSwitchName = null
)
fun shouldVoluntarilySwitch(
myPokemon: TrackedPokemon,
opponentPokemon: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan,
availableSwitches: List<BattlePokemon>
): Boolean {
if (availableSwitches.isEmpty()) {
lastDiagnostics = lastDiagnostics.copy(
shouldSwitch = false,
reason = "No legal bench options available."
)
return false
}
val currentPokemon = myPokemon.pokemon
if (currentPokemon == null) {
lastDiagnostics = lastDiagnostics.copy(
shouldSwitch = false,
reason = "Current battler data missing."
)
return false
}
val scored = availableSwitches.map { option ->
option to evaluateSwitchOption(option.effectedPokemon, opponentPokemon, battleState, gamePlan)
}.sortedByDescending { it.second }
val bestPair = scored.first()
val bestSwitch = bestPair.first.effectedPokemon
val bestScore = bestPair.second
val currentScore = evaluateSwitchOption(currentPokemon, opponentPokemon, battleState, gamePlan)
val scoreGain = bestScore - currentScore
val currentThreat = defensiveThreatMultiplier(opponentPokemon, currentPokemon)
val bestThreat = defensiveThreatMultiplier(opponentPokemon, bestSwitch)
val currentPressure = offensivePressureMultiplier(currentPokemon, opponentPokemon)
val bestPressure = offensivePressureMultiplier(bestSwitch, opponentPokemon)
val mySpeed = effectiveStat(currentPokemon, Stats.SPEED)
val oppSpeed = opponentPokemon.pokemon?.let { effectiveStat(it, Stats.SPEED) } ?: opponentPokemon.cachedSpeed
val speedDisadvantage = oppSpeed > mySpeed * 1.08
val lowHealth = myPokemon.currentHpPercent <= 0.55
val opponentBoosted = opponentPokemon.boosts.values.any { it >= 2 }
val currentPositionPoor = currentScore <= 12.0
val severeDisadvantage =
(currentThreat >= 2.0 && (speedDisadvantage || lowHealth || opponentBoosted)) ||
(currentThreat >= 1.5 && lowHealth) ||
(opponentBoosted && currentPositionPoor) ||
(myPokemon.currentHpPercent <= 0.35 && currentPositionPoor)
val threatImprovement = currentThreat - bestThreat
val pressureImprovement = bestPressure - currentPressure
val strongCounterFound =
scoreGain >= 45.0 &&
bestScore >= 70.0 &&
currentScore <= 25.0 &&
(
threatImprovement >= 0.5 ||
pressureImprovement >= 0.75 ||
effectiveStat(bestSwitch, Stats.SPEED) > oppSpeed * 1.15
)
val shouldSwitch =
(severeDisadvantage && scoreGain >= 18.0) ||
strongCounterFound
val reason = when {
shouldSwitch && severeDisadvantage && strongCounterFound ->
"Switching: severe matchup risk and a strong counter exists."
shouldSwitch && severeDisadvantage ->
"Switching: severe disadvantage in current matchup."
shouldSwitch && strongCounterFound ->
"Switching: found a clearly better counter from bench."
else ->
"Staying in: disadvantage not severe enough and no clear counter upgrade."
}
lastDiagnostics = SwitchDecisionDiagnostics(
shouldSwitch = shouldSwitch,
reason = reason,
currentScore = currentScore,
bestScore = bestScore,
scoreGain = scoreGain,
severeDisadvantage = severeDisadvantage,
strongCounterFound = strongCounterFound,
currentThreat = currentThreat,
bestThreat = bestThreat,
currentPressure = currentPressure,
bestPressure = bestPressure,
bestSwitchName = bestSwitch.species.name
)
return shouldSwitch
}
fun findBestSwitch(
availableSwitches: List<BattlePokemon>,
opponentPokemon: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): BattlePokemon? {
return delegate.findBestSwitch(availableSwitches, opponentPokemon, battleState, gamePlan)
}
fun evaluateSwitchOption(
candidate: Pokemon,
opponent: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
return delegate.evaluateSwitchOption(candidate, opponent, battleState, gamePlan)
}
fun latestSwitchRationale(): String {
val d = lastDiagnostics
return buildString {
append(d.reason)
append(" ")
append(
"currentScore=${"%.1f".format(d.currentScore)}, " +
"bestScore=${"%.1f".format(d.bestScore)}, " +
"gain=${"%.1f".format(d.scoreGain)}"
)
append(" | ")
append(
"currentThreat=${"%.2f".format(d.currentThreat)}, " +
"bestThreat=${"%.2f".format(d.bestThreat)}, " +
"currentPressure=${"%.2f".format(d.currentPressure)}, " +
"bestPressure=${"%.2f".format(d.bestPressure)}"
)
append(" | ")
append(
"severeDisadvantage=${d.severeDisadvantage}, " +
"strongCounterFound=${d.strongCounterFound}, " +
"bestSwitch=${d.bestSwitchName ?: "none"}"
)
}
}
fun latestDiagnosticsSnapshot(): SwitchDecisionDiagnostics = lastDiagnostics
private fun offensivePressureMultiplier(attacker: Pokemon, defender: TrackedPokemon): Double {
val defenderTypes = defender.currentTypes ?: defender.pokemon?.types?.map { it.name.lowercase() } ?: listOf()
return bestMoveEffectiveness(attacker.moveSet.getMoves(), defenderTypes)
}
private fun defensiveThreatMultiplier(opponent: TrackedPokemon, candidate: Pokemon): Double {
val candidateTypes = candidate.types.map { it.name.lowercase() }
val opponentMoves = opponent.moves.ifEmpty { opponent.pokemon?.moveSet?.getMoves() ?: listOf() }
return bestMoveEffectiveness(opponentMoves, candidateTypes)
}
private fun bestMoveEffectiveness(moves: List<Move>, defenderTypes: List<String>): Double {
if (moves.isEmpty() || defenderTypes.isEmpty()) return 1.0
return moves.maxOfOrNull { move ->
TypeEffectiveness.calculateTypeEffectiveness(move.type, defenderTypes)
} ?: 1.0
}
private fun effectiveStat(pokemon: Pokemon, stat: Stat): Double {
val base = pokemon.species.baseStats[stat] ?: 0
val iv = pokemon.ivs[stat] ?: 31
val ev = pokemon.evs[stat] ?: 0
val level = pokemon.level
return if (stat == Stats.HP) {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + level + 10.0)
} else {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + 5.0)
}
}
}
@@ -0,0 +1,11 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.types.ElementalType
object NewTypeEffectiveness {
fun getDamageMultiplier(attackerType: ElementalType, defenderType: ElementalType): Double =
TypeEffectiveness.getDamageMultiplier(attackerType, defenderType)
fun calculateTypeEffectiveness(moveType: ElementalType, defenderTypes: List<String>): Double =
TypeEffectiveness.calculateTypeEffectiveness(moveType, defenderTypes)
}
@@ -0,0 +1,142 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.moves.Moves
import kotlin.math.min
class OpponentPredictor {
private val moveHistory = mutableMapOf<String, MutableList<String>>()
private val switchHistory = mutableMapOf<String, MutableList<String>>()
private val situationResponses = mutableMapOf<String, MutableMap<String, Int>>()
private val aggressionLevel = mutableMapOf<String, Double>()
private val setupTendency = mutableMapOf<String, Double>()
private val switchFrequency = mutableMapOf<String, Double>()
fun updateMoveHistory(pokemon: String, move: String, situation: String) {
moveHistory.getOrPut(pokemon) { mutableListOf() }.add(move)
situationResponses.getOrPut(situation) { mutableMapOf() }.merge(move, 1, Int::plus)
val moveData = Moves.getByName(move)
if (moveData != null) {
val currentAggression = aggressionLevel[pokemon] ?: 0.5
val newAggression = if (moveData.power > 0) {
currentAggression * 0.9 + 0.8 * 0.1
} else {
currentAggression * 0.9 + 0.2 * 0.1
}
aggressionLevel[pokemon] = newAggression
if (MoveData.boostFromMoves.containsKey(move.lowercase())) {
val currentSetup = setupTendency[pokemon] ?: 0.3
setupTendency[pokemon] = min(1.0, currentSetup + 0.15)
}
}
}
fun updateSwitchPattern(fromPokemon: String, toPokemon: String, reason: String) {
switchHistory.getOrPut(fromPokemon) { mutableListOf() }.add(toPokemon)
situationResponses.getOrPut("switch_$reason") { mutableMapOf() }.merge(toPokemon, 1, Int::plus)
val totalActions = (moveHistory[fromPokemon]?.size ?: 0) + (switchHistory[fromPokemon]?.size ?: 0)
if (totalActions > 0) {
switchFrequency[fromPokemon] = (switchHistory[fromPokemon]?.size ?: 0).toDouble() / totalActions
}
}
fun predictNextMove(pokemon: String, situation: String): Map<String, Double> {
val responses = situationResponses[situation] ?: return emptyMap()
val total = responses.values.sum().toDouble()
return if (total > 0) {
responses.mapValues { it.value / total }
} else {
emptyMap()
}
}
fun getSwitchProbability(pokemon: String, situation: String): Double {
val switches = switchHistory[pokemon]?.size ?: 0
val totalActions = (moveHistory[pokemon]?.size ?: 0) + switches
if (totalActions < 5) return 0.2
return min(0.8, switches.toDouble() / totalActions + 0.1)
}
private fun getAggressionLevel(pokemon: String): Double = aggressionLevel[pokemon] ?: 0.5
private fun getSetupTendency(pokemon: String): Double = setupTendency[pokemon] ?: 0.3
private fun getSwitchFrequency(pokemon: String): Double = switchFrequency[pokemon] ?: 0.2
fun predictMoveHeuristically(
opponent: TrackedPokemon,
myPokemon: TrackedPokemon
): Map<String, Double> {
val predictions = mutableMapOf<String, Double>()
val aggression = getAggressionLevel(opponent.species ?: "unknown")
for (moveName in opponent.revealedMoves) {
val moveData = Moves.getByName(moveName) ?: continue
var probability = 0.25
probability *= if (moveData.power > 0) {
aggression + 0.3
} else {
(1.0 - aggression) + 0.3
}
val myTypes = myPokemon.currentTypes ?: listOf()
val effectiveness = TypeEffectiveness.calculateTypeEffectiveness(moveData.elementalType, myTypes)
probability *= (0.5 + effectiveness)
predictions[moveName] = probability
}
val total = predictions.values.sum()
return if (total > 0) {
predictions.mapValues { it.value / total }
} else {
emptyMap()
}
}
fun predictSwitchTarget(
fromPokemon: String,
myActivePokemon: TrackedPokemon
): Map<String, Double> {
val pastSwitches = switchHistory[fromPokemon] ?: return emptyMap()
val switchCounts = mutableMapOf<String, Int>()
for (target in pastSwitches) {
switchCounts.merge(target, 1, Int::plus)
}
val total = switchCounts.values.sum().toDouble()
return if (total > 0) {
switchCounts.mapValues { it.value / total }
} else {
emptyMap()
}
}
fun categorizePlaystyle(pokemon: String): OpponentPlaystyle {
val aggression = getAggressionLevel(pokemon)
val setupTend = getSetupTendency(pokemon)
val switchFreq = getSwitchFrequency(pokemon)
return when {
setupTend > 0.5 -> OpponentPlaystyle.SETUP_ORIENTED
aggression > 0.7 && switchFreq < 0.3 -> OpponentPlaystyle.HYPER_OFFENSIVE
switchFreq > 0.5 -> OpponentPlaystyle.MOMENTUM_BASED
aggression < 0.4 -> OpponentPlaystyle.DEFENSIVE_STALL
else -> OpponentPlaystyle.BALANCED
}
}
fun reset() {
moveHistory.clear()
switchHistory.clear()
situationResponses.clear()
aggressionLevel.clear()
setupTendency.clear()
switchFrequency.clear()
}
}
enum class OpponentPlaystyle {
HYPER_OFFENSIVE,
SETUP_ORIENTED,
MOMENTUM_BASED,
DEFENSIVE_STALL,
BALANCED
}
@@ -0,0 +1,320 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
import com.cobblemon.mod.common.pokemon.Pokemon
import kotlin.math.max
class StrategyAnalyzer {
fun analyzeTeamComposition(team: List<Pokemon>): GamePlan {
val roles = team.map { identifyPokemonRole(it) }
val sweepers = roles.count { it == PokemonRole.SWEEPER }
val setupSweepers = roles.count { it == PokemonRole.SETUP_SWEEPER }
val wallbreakers = roles.count { it == PokemonRole.WALLBREAKER }
val supports = roles.count { it in listOf(PokemonRole.SUPPORT, PokemonRole.TANK) }
val revengeKillers = roles.count { it == PokemonRole.REVENGE_KILLER }
val weatherStrategy = identifyWeatherStrategy(team)
if (weatherStrategy != null) {
return createWeatherGamePlan(weatherStrategy, team)
}
val hazardStrategy = identifyHazardStrategy(team)
if (hazardStrategy) {
return createHazardGamePlan(team)
}
val winCondition = when {
setupSweepers >= 2 && supports >= 2 -> WinCondition.SETUP_SWEEP
sweepers + wallbreakers >= 4 -> WinCondition.AGGRESSIVE_SWEEP
supports >= 3 -> WinCondition.DEFENSIVE_STALL
revengeKillers >= 2 -> WinCondition.REVENGE_KILLING
else -> WinCondition.BALANCED
}
return createGamePlan(winCondition, team, roles)
}
private fun identifyWeatherStrategy(team: List<Pokemon>): WinCondition? {
val rainSetters = team.count { hasWeatherSettingMove(it, "rain") || hasWeatherSettingAbility(it, "rain") }
val rainAbusers = team.count { hasWeatherAbility(it, "rain") || hasWeatherMoves(it, "rain") }
val sunSetters = team.count { hasWeatherSettingMove(it, "sun") || hasWeatherSettingAbility(it, "sun") }
val sunAbusers = team.count { hasWeatherAbility(it, "sun") || hasWeatherMoves(it, "sun") }
val sandSetters = team.count { hasWeatherSettingMove(it, "sand") || hasWeatherSettingAbility(it, "sand") }
val sandAbusers = team.count { hasWeatherAbility(it, "sand") }
return when {
rainSetters >= 1 && rainAbusers >= 2 -> WinCondition.RAIN_OFFENSE
sunSetters >= 1 && sunAbusers >= 2 -> WinCondition.SUN_OFFENSE
sandSetters >= 1 && sandAbusers >= 2 -> WinCondition.SAND_OFFENSE
rainSetters >= 1 || sunSetters >= 1 || sandSetters >= 1 -> WinCondition.WEATHER_CONTROL
else -> null
}
}
private fun identifyHazardStrategy(team: List<Pokemon>): Boolean {
val hazardSetters = team.count { pokemon ->
pokemon.moveSet.getMoves().any { move ->
val id = move.name.lowercase()
id in MoveData.entryHazards || "hazard_set" in BattleAITags.getMoveTags(id)
}
}
val hazardRemovers = team.count { pokemon ->
pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.antiHazardsMoves }
}
return hazardSetters >= 2 && hazardRemovers >= 1
}
private fun createWeatherGamePlan(winCondition: WinCondition, team: List<Pokemon>): GamePlan {
val weatherType = when (winCondition) {
WinCondition.RAIN_OFFENSE -> "rain"
WinCondition.SUN_OFFENSE -> "sun"
WinCondition.SAND_OFFENSE -> "sand"
else -> "any"
}
val setters = team.filter { hasWeatherSettingMove(it, weatherType) || hasWeatherSettingAbility(it, weatherType) }
val abusers = team.filter { hasWeatherAbility(it, weatherType) || hasWeatherMoves(it, weatherType) }
val keyPokemon = (setters + abusers).distinct().map { it.species.name }
val priorities = when (winCondition) {
WinCondition.RAIN_OFFENSE -> listOf(
"set_rain_immediately",
"preserve_rain_setter",
"sweep_with_rain_abusers",
"use_perfect_accuracy_moves",
"prevent_weather_changes"
)
WinCondition.SUN_OFFENSE -> listOf(
"set_sun_immediately",
"preserve_sun_setter",
"sweep_with_chlorophyll",
"use_solar_beam",
"prevent_weather_changes"
)
WinCondition.SAND_OFFENSE -> listOf(
"set_sandstorm",
"abuse_sand_rush",
"chip_damage_from_sand",
"prevent_weather_changes"
)
else -> listOf("maintain_weather", "use_weather_abusers")
}
return GamePlan(
winCondition = winCondition,
keyPokemon = keyPokemon,
priorities = priorities,
riskyMoves = MoveData.highRiskMoves,
aggressionLevel = 0.8,
setupPriority = 0.9
)
}
private fun createHazardGamePlan(team: List<Pokemon>): GamePlan {
val hazardSetters = team.filter { pokemon ->
pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.entryHazards }
}.map { it.species.name }
return GamePlan(
winCondition = WinCondition.HAZARD_CONTROL,
keyPokemon = hazardSetters,
priorities = listOf(
"set_stealth_rock_early",
"stack_hazards_if_safe",
"remove_opponent_hazards",
"force_switches",
"preserve_hazard_setters"
),
riskyMoves = listOf("explosion"),
aggressionLevel = 0.6,
setupPriority = 0.7
)
}
private fun createGamePlan(winCondition: WinCondition, team: List<Pokemon>, roles: List<PokemonRole>): GamePlan {
val keyPokemon = identifyKeyPokemon(winCondition, team, roles)
val priorities = when (winCondition) {
WinCondition.SETUP_SWEEP -> listOf(
"create_setup_opportunity",
"execute_setup_moves",
"sweep_after_setup",
"preserve_setup_sweeper",
"remove_priority_threats"
)
WinCondition.AGGRESSIVE_SWEEP -> listOf(
"maintain_offensive_pressure",
"use_coverage_moves",
"preserve_sweepers",
"use_priority_to_finish",
"momentum_over_setup"
)
WinCondition.DEFENSIVE_STALL -> listOf(
"spread_status_conditions",
"use_recovery_moves",
"set_up_hazards",
"stall_out_threats",
"remove_opponent_hazards"
)
WinCondition.REVENGE_KILLING -> listOf(
"preserve_revenge_killer",
"use_priority_strategically",
"chip_into_priority_range",
"scout_with_protect",
"maintain_momentum"
)
WinCondition.HAZARD_CONTROL -> listOf(
"set_stealth_rock_early",
"stack_hazards_safely",
"remove_opponent_hazards",
"force_switches"
)
else -> listOf(
"maintain_balance",
"adapt_to_opponent",
"resource_management",
"take_calculated_risks"
)
}
val riskyMoves = when (winCondition) {
WinCondition.SETUP_SWEEP -> MoveData.selfKoMoves
WinCondition.REVENGE_KILLING -> MoveData.highRiskMoves
WinCondition.DEFENSIVE_STALL -> MoveData.highRiskMoves
else -> MoveData.selfKoMoves
}
val aggressionLevel = when (winCondition) {
WinCondition.AGGRESSIVE_SWEEP -> 0.9
WinCondition.SETUP_SWEEP -> 0.7
WinCondition.REVENGE_KILLING -> 0.75
WinCondition.DEFENSIVE_STALL -> 0.3
else -> 0.6
}
val setupPriority = when (winCondition) {
WinCondition.SETUP_SWEEP -> 0.95
WinCondition.DEFENSIVE_STALL -> 0.6
else -> 0.5
}
return GamePlan(
winCondition = winCondition,
keyPokemon = keyPokemon,
priorities = priorities,
riskyMoves = riskyMoves,
aggressionLevel = aggressionLevel,
setupPriority = setupPriority
)
}
private fun identifyKeyPokemon(
winCondition: WinCondition,
team: List<Pokemon>,
roles: List<PokemonRole>
): List<String> {
return when (winCondition) {
WinCondition.SETUP_SWEEP -> team.filterIndexed { index, _ -> roles[index] == PokemonRole.SETUP_SWEEPER }.map { it.species.name }
WinCondition.AGGRESSIVE_SWEEP -> team.filterIndexed { index, _ ->
roles[index] in listOf(PokemonRole.SWEEPER, PokemonRole.WALLBREAKER)
}.map { it.species.name }
WinCondition.REVENGE_KILLING -> team.filterIndexed { index, _ -> roles[index] == PokemonRole.REVENGE_KILLER }.map { it.species.name }
WinCondition.DEFENSIVE_STALL -> team.filterIndexed { index, _ ->
roles[index] in listOf(PokemonRole.TANK, PokemonRole.SUPPORT)
}.map { it.species.name }
else -> team.take(2).map { it.species.name }
}
}
fun identifyPokemonRole(pokemon: Pokemon): PokemonRole {
val attack = pokemon.species.baseStats[Stats.ATTACK] ?: 0
val spAttack = pokemon.species.baseStats[Stats.SPECIAL_ATTACK] ?: 0
val defense = pokemon.species.baseStats[Stats.DEFENCE] ?: 0
val spDefense = pokemon.species.baseStats[Stats.SPECIAL_DEFENCE] ?: 0
val speed = pokemon.species.baseStats[Stats.SPEED] ?: 0
val hp = pokemon.species.baseStats[Stats.HP] ?: 0
val maxAttack = max(attack, spAttack)
val avgDefense = (defense + spDefense) / 2.0
val bulk = hp + avgDefense
val hasSetupMoves = pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.boostFromMoves.keys }
val hasPriorityMoves = pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.priorityMoves }
val hasPivotMoves = pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.pivotMoves }
val hasRecoveryMoves = pokemon.moveSet.getMoves().any { it.name.lowercase() in MoveData.selfRecoveryMoves }
return when {
hasPivotMoves -> PokemonRole.PIVOT
hasSetupMoves && speed >= 90 && maxAttack >= 90 -> PokemonRole.SETUP_SWEEPER
speed >= 110 && hasPriorityMoves -> PokemonRole.REVENGE_KILLER
speed >= 100 && maxAttack >= 100 -> PokemonRole.SWEEPER
maxAttack >= 120 -> PokemonRole.WALLBREAKER
bulk >= 180 && maxAttack < 80 -> PokemonRole.TANK
hasRecoveryMoves || hasSetupMoves -> PokemonRole.SUPPORT
else -> PokemonRole.BALANCED
}
}
private fun hasWeatherSettingMove(pokemon: Pokemon, weather: String): Boolean {
val moves = pokemon.moveSet.getMoves().map { it.name.lowercase() }
return moves.any { moveId -> canonicalWeather(MoveData.weatherMoves[moveId]) == weather }
}
private fun hasWeatherSettingAbility(pokemon: Pokemon, weather: String): Boolean {
val ability = pokemon.ability.name.lowercase()
val abilityTags = BattleAITags.getAbilityTags(ability)
if ("weather_setter" !in abilityTags) {
return false
}
val conditional = abilityTags.firstOrNull { it.startsWith("conditional_weather_") }?.removePrefix("conditional_weather_")
return conditional == null || canonicalWeather(conditional) == weather
}
private fun hasWeatherAbility(pokemon: Pokemon, weather: String): Boolean {
val ability = pokemon.ability.name.lowercase()
val abilityTags = BattleAITags.getAbilityTags(ability)
if (abilityTags.any { it == "weather_interaction" || it == "self_chip_weather" }) {
val conditional = abilityTags.firstOrNull { it.startsWith("conditional_weather_") }?.removePrefix("conditional_weather_")
if (conditional == null) {
return true
}
return canonicalWeather(conditional) == weather
}
return canonicalWeather(MoveData.weatherAbilities[ability]) == weather
}
private fun hasWeatherMoves(pokemon: Pokemon, weather: String): Boolean {
val moves = pokemon.moveSet.getMoves().map { it.name.lowercase() }
return moves.any { moveId -> canonicalWeather(MoveData.weatherDependentMoves[moveId]) == weather }
}
private fun canonicalWeather(raw: String?): String? {
return when (raw?.lowercase()) {
null -> null
"rain", "raindance", "raining", "heavyrain" -> "rain"
"sun", "sunny", "sunnyday", "harshsunlight", "desolateland" -> "sun"
"sand", "sandstorm" -> "sand"
"hail" -> "hail"
"snow", "snowscape" -> "snow"
else -> raw.lowercase()
}
}
}
enum class WinCondition {
AGGRESSIVE_SWEEP,
SETUP_SWEEP,
DEFENSIVE_STALL,
HAZARD_CONTROL,
REVENGE_KILLING,
BALANCED,
RAIN_OFFENSE,
SUN_OFFENSE,
SAND_OFFENSE,
WEATHER_CONTROL
}
data class GamePlan(
val winCondition: WinCondition,
val keyPokemon: List<String>,
val priorities: List<String>,
val riskyMoves: List<String>,
val aggressionLevel: Double = 0.6,
val setupPriority: Double = 0.5
)
@@ -0,0 +1,237 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.pokemon.stats.Stats
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.pokemon.Pokemon
class SwitchDecisionMaker(
private val damageCalculator: DamageCalculator,
private val opponentPredictor: OpponentPredictor,
private val strategyAnalyzer: StrategyAnalyzer
) {
fun shouldVoluntarilySwitch(
myPokemon: TrackedPokemon,
opponentPokemon: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan,
availableSwitches: List<BattlePokemon>
): Boolean {
if (availableSwitches.isEmpty()) return false
val currentMatchup = evaluateMatchup(myPokemon, opponentPokemon, battleState)
val bestSwitchScore = availableSwitches.maxOfOrNull { switchOption ->
evaluateSwitchOption(switchOption.effectedPokemon, opponentPokemon, battleState, gamePlan)
} ?: 0.0
val switchThreshold = 40.0
if (bestSwitchScore > currentMatchup.overallScore + switchThreshold) {
return true
}
return checkSwitchConditions(
myPokemon,
opponentPokemon,
battleState,
gamePlan,
currentMatchup
)
}
fun findBestSwitch(
availableSwitches: List<BattlePokemon>,
opponentPokemon: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): BattlePokemon? {
if (availableSwitches.isEmpty()) return null
return availableSwitches.maxByOrNull { switchOption ->
evaluateSwitchOption(switchOption.effectedPokemon, opponentPokemon, battleState, gamePlan)
}
}
fun evaluateSwitchOption(
candidate: Pokemon,
opponent: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan
): Double {
var score = 0.0
val candidateTracker = createTrackerPokemon(candidate)
val matchup = evaluateMatchup(candidateTracker, opponent, battleState)
score += matchup.overallScore
val hpFactor = candidateTracker.currentHpPercent
if (hpFactor < 0.5) {
score -= (1.0 - hpFactor) * 40.0
}
if (gamePlan.keyPokemon.contains(candidate.species.name)) {
score += 20.0
}
val mySpeed = getEffectiveStat(candidate, Stats.SPEED)
val oppSpeed = opponent.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
if (mySpeed > oppSpeed) score += 25.0
val hazardDamage = calculateEntryHazardDamage(candidate, battleState.npcHazards)
score -= hazardDamage * 0.5
val role = strategyAnalyzer.identifyPokemonRole(candidate)
score += evaluateRoleSynergy(role, gamePlan, battleState)
return score
}
private fun evaluateMatchup(
myPokemon: TrackedPokemon,
opponent: TrackedPokemon,
battleState: BattleState
): MatchupEvaluation {
val ourBestEffectiveness = calculateBestMoveEffectiveness(myPokemon, opponent)
val offensiveAdvantage = (ourBestEffectiveness - 1.0) * 40.0
val theirBestEffectiveness = calculateBestMoveEffectiveness(opponent, myPokemon)
val defensiveAdvantage = -(theirBestEffectiveness - 1.0) * 35.0
val mySpeed = myPokemon.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
val oppSpeed = opponent.pokemon?.let { getEffectiveStat(it, Stats.SPEED) } ?: 0.0
val speedAdvantage = if (mySpeed > oppSpeed) 20.0 else -15.0
val overallScore = offensiveAdvantage + defensiveAdvantage + speedAdvantage
val recommendation = when {
overallScore > 60 && myPokemon.currentHpPercent > 0.7 -> MatchupRecommendation.STAY_IN_AND_SETUP
overallScore > 30 -> MatchupRecommendation.STAY_IN_AND_ATTACK
overallScore > 0 -> MatchupRecommendation.CONSIDER_SWITCHING
overallScore > -30 -> MatchupRecommendation.PIVOT_OUT
else -> MatchupRecommendation.SWITCH_OUT
}
return MatchupEvaluation(
offensiveAdvantage = offensiveAdvantage,
defensiveAdvantage = defensiveAdvantage,
speedAdvantage = speedAdvantage,
overallScore = overallScore,
recommendation = recommendation
)
}
private fun checkSwitchConditions(
myPokemon: TrackedPokemon,
opponent: TrackedPokemon,
battleState: BattleState,
gamePlan: GamePlan,
matchup: MatchupEvaluation
): Boolean {
val theirBestEffectiveness = calculateBestMoveEffectiveness(opponent, myPokemon)
if (theirBestEffectiveness >= 2.0 && myPokemon.currentHpPercent > 0.3) {
return true
}
if (myPokemon.currentHpPercent < 0.3 && matchup.overallScore < 30) {
return true
}
if (opponent.boosts.values.any { it >= 2 } && matchup.overallScore < 20) {
return true
}
if (myPokemon.species in gamePlan.keyPokemon &&
myPokemon.currentHpPercent < 0.5 &&
theirBestEffectiveness > 1.0
) {
return true
}
return false
}
private fun calculateBestMoveEffectiveness(
attacker: TrackedPokemon,
defender: TrackedPokemon
): Double {
val moves = attacker.pokemon?.moveSet?.getMoves() ?: return 1.0
return moves.maxOfOrNull { move ->
TypeEffectiveness.calculateTypeEffectiveness(move.type, defender.currentTypes ?: listOf())
} ?: 1.0
}
private fun calculateEntryHazardDamage(pokemon: Pokemon, hazards: List<String>): Double {
var damage = 0.0
val maxHP = getEffectiveStat(pokemon, Stats.HP)
for (hazard in hazards) {
when (hazard.lowercase()) {
"stealthrock" -> {
val rockEffectiveness = pokemon.types.fold(1.0) { acc, type ->
acc * TypeEffectiveness.getDamageMultiplier(com.cobblemon.mod.common.api.types.ElementalTypes.ROCK, type)
}
damage += maxHP * rockEffectiveness * 0.125
}
"spikes" -> damage += maxHP * 0.125
"toxicspikes" -> {
if (!pokemon.types.any { it.name.lowercase() in listOf("poison", "steel") }) {
damage += maxHP * 0.15
}
}
"stickyweb" -> damage += 10.0
}
}
return damage
}
private fun evaluateRoleSynergy(
role: PokemonRole,
gamePlan: GamePlan,
battleState: BattleState
): Double {
return when (gamePlan.winCondition) {
WinCondition.SETUP_SWEEP -> when (role) {
PokemonRole.SETUP_SWEEPER -> 50.0
PokemonRole.SUPPORT -> if (battleState.gamePhase == GamePhase.EARLY_GAME) 30.0 else 10.0
else -> 0.0
}
WinCondition.AGGRESSIVE_SWEEP -> when (role) {
PokemonRole.SWEEPER, PokemonRole.WALLBREAKER -> 40.0
PokemonRole.REVENGE_KILLER -> 30.0
else -> 0.0
}
WinCondition.DEFENSIVE_STALL -> when (role) {
PokemonRole.TANK, PokemonRole.SUPPORT -> 45.0
else -> -10.0
}
WinCondition.REVENGE_KILLING -> when (role) {
PokemonRole.REVENGE_KILLER -> 60.0
PokemonRole.PIVOT -> 25.0
else -> 0.0
}
WinCondition.RAIN_OFFENSE, WinCondition.SUN_OFFENSE, WinCondition.SAND_OFFENSE -> when (role) {
PokemonRole.SWEEPER -> 35.0
else -> 0.0
}
else -> 0.0
}
}
private fun createTrackerPokemon(pokemon: Pokemon): TrackedPokemon {
return TrackedPokemon(
pokemon = pokemon,
originalPokemon = pokemon,
species = pokemon.species.name,
currentHp = pokemon.currentHealth,
maxHp = pokemon.maxHealth,
currentHpPercent = pokemon.currentHealth.toDouble() / pokemon.maxHealth,
currentTypes = pokemon.types.map { it.name.lowercase() }.toMutableList(),
currentAbility = pokemon.ability.name,
moves = pokemon.moveSet.getMoves(),
predictedRole = strategyAnalyzer.identifyPokemonRole(pokemon),
cachedAttack = getEffectiveStat(pokemon, Stats.ATTACK),
cachedDefense = getEffectiveStat(pokemon, Stats.DEFENCE),
cachedSpAttack = getEffectiveStat(pokemon, Stats.SPECIAL_ATTACK),
cachedSpDefense = getEffectiveStat(pokemon, Stats.SPECIAL_DEFENCE),
cachedSpeed = getEffectiveStat(pokemon, Stats.SPEED)
)
}
private fun getEffectiveStat(pokemon: Pokemon, stat: Stats): Double {
val base = pokemon.species.baseStats[stat] ?: 0
val iv = pokemon.ivs[stat] ?: 31
val ev = pokemon.evs[stat] ?: 0
val level = pokemon.level
return if (stat == Stats.HP) {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + level + 10.0)
} else {
((2.0 * base + iv + ev / 4.0) * level / 100.0 + 5.0)
}
}
}
@@ -0,0 +1,36 @@
package com.cobblemon.mod.common.battles.ai.competitive
import com.cobblemon.mod.common.api.types.ElementalType
import com.cobblemon.mod.common.api.types.ElementalTypes
import com.cobblemon.mod.common.battles.ai.strongBattleAI.AIUtility
object TypeEffectiveness {
fun getDamageMultiplier(attackerType: ElementalType, defenderType: ElementalType): Double {
return AIUtility.getDamageMultiplier(attackerType, defenderType)
}
fun calculateTypeEffectiveness(moveType: ElementalType, defenderTypes: List<String>): Double {
var multiplier = 1.0
for (typeString in defenderTypes) {
val defenderType = ElementalTypes.get(typeString.lowercase()) ?: continue
multiplier *= getDamageMultiplier(moveType, defenderType)
}
return multiplier
}
fun getEffectivenessCategory(effectiveness: Double): EffectivenessCategory {
return when {
effectiveness == 0.0 -> EffectivenessCategory.IMMUNE
effectiveness < 1.0 -> EffectivenessCategory.NOT_VERY_EFFECTIVE
effectiveness == 1.0 -> EffectivenessCategory.NEUTRAL
else -> EffectivenessCategory.SUPER_EFFECTIVE
}
}
enum class EffectivenessCategory {
IMMUNE,
NOT_VERY_EFFECTIVE,
NEUTRAL,
SUPER_EFFECTIVE
}
}
@@ -0,0 +1,199 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.battles.ai.strongBattleAI
import com.cobblemon.mod.common.Cobblemon
import com.cobblemon.mod.common.api.data.DataRegistry
import com.cobblemon.mod.common.api.reactive.SimpleObservable
import com.cobblemon.mod.common.util.cobblemonResource
import com.google.gson.GsonBuilder
import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerPlayer
import net.minecraft.server.packs.PackType
import net.minecraft.server.packs.resources.ResourceManager
import java.io.File
/**
* Registry for optional tags that can be attached to moves and abilities for StrongBattleAI heuristics.
*/
object BattleAITags : DataRegistry {
override val id = cobblemonResource("battle_ai_tags")
override val type = PackType.SERVER_DATA
override val observable = SimpleObservable<BattleAITags>()
private val gson = GsonBuilder()
.disableHtmlEscaping()
.setPrettyPrinting()
.create()
private val moveTagsById = mutableMapOf<String, MutableSet<String>>()
private val abilityTagsById = mutableMapOf<String, MutableSet<String>>()
private val abilityTagMultipliersById = mutableMapOf<String, MutableMap<String, Double>>()
private val abilityFolders = listOf(
"battle_ai/abilities",
"battle_ai/abilties",
"battleai/abilities",
"battleai/abilties"
)
private val moveFolders = listOf(
"battle_ai/moves",
"battleai/moves"
)
private const val DEFAULT_BOOST_TAG_MULTIPLIER = 1.5
override fun sync(player: ServerPlayer) {}
override fun reload(manager: ResourceManager) {
moveTagsById.clear()
abilityTagsById.clear()
abilityTagMultipliersById.clear()
loadMoveTags(manager)
loadAbilityTags(manager)
Cobblemon.LOGGER.info(
"Loaded battle AI tags: {} moves, {} abilities, {} ability-tag multipliers",
moveTagsById.size,
abilityTagsById.size,
abilityTagMultipliersById.values.sumOf { it.size }
)
observable.emit(this)
}
fun getMoveTags(moveId: String?): Set<String> {
val normalizedMoveId = normalizeId(moveId) ?: return emptySet()
return moveTagsById[normalizedMoveId]?.toSet() ?: emptySet()
}
fun getAbilityTags(abilityId: String?): Set<String> {
val normalizedAbilityId = normalizeId(abilityId) ?: return emptySet()
return abilityTagsById[normalizedAbilityId]?.toSet() ?: emptySet()
}
fun abilityMoveTagDamageMultiplier(abilityId: String?, moveId: String?): Double {
val normalizedAbilityId = normalizeId(abilityId) ?: return 1.0
val abilityTags = getAbilityTags(normalizedAbilityId)
val moveTags = getMoveTags(moveId)
if (abilityTags.isEmpty() || moveTags.isEmpty()) {
return 1.0
}
var multiplier = 1.0
val appliedMoveTags = mutableSetOf<String>()
abilityTagMultipliersById[normalizedAbilityId]?.forEach { (moveTag, tagMultiplier) ->
if (moveTag in moveTags) {
multiplier *= tagMultiplier
appliedMoveTags += moveTag
}
}
abilityTags
.asSequence()
.filter { it.startsWith("boost_") }
.map { it.removePrefix("boost_") }
.filter { it.isNotBlank() && it in moveTags && it !in appliedMoveTags }
.forEach {
multiplier *= DEFAULT_BOOST_TAG_MULTIPLIER
appliedMoveTags += it
}
if ("boost" in abilityTags) {
abilityTags
.asSequence()
.filter { it != "boost" && !it.startsWith("boost_") }
.filter { it in moveTags && it !in appliedMoveTags }
.forEach {
multiplier *= DEFAULT_BOOST_TAG_MULTIPLIER
appliedMoveTags += it
}
}
return multiplier
}
private fun normalizeId(id: String?): String? = normalizeTag(id)
private fun normalizeTags(tags: Iterable<String>): Set<String> {
return tags.mapNotNull(::normalizeTag).toSet()
}
private fun normalizeTag(tag: String?): String? {
val normalizedTag = tag?.trim()?.lowercase()
return if (normalizedTag.isNullOrBlank()) null else normalizedTag
}
private fun loadMoveTags(manager: ResourceManager) {
moveFolders.forEach { folder ->
manager.listResources(folder) { it.path.endsWith(".json") }.forEach { (identifier, resource) ->
val moveId = normalizeId(File(identifier.path).nameWithoutExtension) ?: return@forEach
resource.open().use { stream ->
stream.bufferedReader().use { reader ->
try {
val entry = gson.fromJson(reader, MoveTagEntry::class.java) ?: MoveTagEntry()
val tags = normalizeTags(entry.tags)
if (tags.isNotEmpty()) {
moveTagsById.computeIfAbsent(moveId) { mutableSetOf() }.addAll(tags)
}
} catch (exception: JsonSyntaxException) {
Cobblemon.LOGGER.error("Failed to parse move battle AI tags for {}", identifier, exception)
}
}
}
}
}
}
private fun loadAbilityTags(manager: ResourceManager) {
abilityFolders.forEach { folder ->
manager.listResources(folder) { it.path.endsWith(".json") }.forEach { (identifier, resource) ->
val abilityId = normalizeId(File(identifier.path).nameWithoutExtension) ?: return@forEach
resource.open().use { stream ->
stream.bufferedReader().use { reader ->
try {
val entry = gson.fromJson(reader, AbilityTagEntry::class.java) ?: AbilityTagEntry()
val tags = normalizeTags(entry.tags)
if (tags.isNotEmpty()) {
abilityTagsById.computeIfAbsent(abilityId) { mutableSetOf() }.addAll(tags)
}
val tagMultipliers = entry.tagMultipliers
.mapNotNull { (tag, multiplier) ->
val normalizedTag = normalizeTag(tag) ?: return@mapNotNull null
if (multiplier <= 0.0) return@mapNotNull null
normalizedTag to multiplier
}
.toMap()
if (tagMultipliers.isNotEmpty()) {
abilityTagMultipliersById
.computeIfAbsent(abilityId) { mutableMapOf() }
.putAll(tagMultipliers)
}
} catch (exception: JsonSyntaxException) {
Cobblemon.LOGGER.error("Failed to parse ability battle AI tags for {}", identifier, exception)
}
}
}
}
}
}
}
data class AbilityTagEntry(
@SerializedName("tags")
val tags: List<String> = emptyList(),
@SerializedName("tag_multipliers")
val tagMultipliers: Map<String, Double> = emptyMap()
)
data class MoveTagEntry(
@SerializedName("tags")
val tags: List<String> = emptyList()
)
@@ -23,17 +23,19 @@ import com.cobblemon.mod.common.util.battleLang
* @author Yaseen
* @since April 22nd, 2023
*/
class ErrorInstruction(val battleActor: BattleActor, val message: BattleMessage): InterpreterInstruction {
class ErrorInstruction(val battleActor: BattleActor, val message: BattleMessage) : InterpreterInstruction {
override fun invoke(battle: PokemonBattle) {
battle.log("Error Instruction")
battle.dispatchGo {
//TODO: some lang stuff for the error messages (Whats the protocol for adding to other langs )
//Also is it okay to ignore the team preview error for now? - You bet!
val lang = when(message.rawMessage) {
"|error|[Unavailable choice] Can't switch: The active Pokémon is trapped" -> battleLang("error.pokemon_is_trapped").red()
// TODO: add localized messages for all error variants.
val lang = when (message.rawMessage) {
"|error|[Invalid choice] Can't choose for Team Preview: You're not in a Team Preview phase" -> return@dispatchGo
"|error|[Invalid choice] Can't do anything: It's not your turn" -> return@dispatchGo
"|error|[Invalid choice] Can't undo: A trapping/disabling effect would cause undo to leak information" -> return@dispatchGo
"|error|[Unavailable choice] Can't switch: The active Pokémon is trapped" ->
battleLang("error.pokemon_is_trapped").red()
else -> battle.createUnimplemented(message)
}
battleActor.sendMessage(lang)
@@ -41,4 +43,4 @@ class ErrorInstruction(val battleActor: BattleActor, val message: BattleMessage)
battleActor.sendUpdate(BattleMadeInvalidChoicePacket())
}
}
}
}
@@ -36,8 +36,8 @@ class RequestInstruction(val battleActor: BattleActor, val message: BattleMessag
request.sanitize(battle, battleActor)
battle.dispatchGo {
// This request won't be acted on until the start of next turn
battleActor.sendUpdate(BattleQueueRequestPacket(request))
battleActor.request = request
battleActor.sendUpdate(BattleQueueRequestPacket(request))
battleActor.responses.clear()
// We need to send this out because 'upkeep' isn't received until the request is handled since the turn won't swap
if (request.forceSwitch.contains(true)) {
@@ -49,4 +49,4 @@ class RequestInstruction(val battleActor: BattleActor, val message: BattleMessag
}
}
}
}
@@ -10,17 +10,9 @@ package com.cobblemon.mod.common.battles.interpreter.instructions
import com.cobblemon.mod.common.api.battles.interpreter.BattleMessage
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
import com.cobblemon.mod.common.api.scheduling.afterOnServer
import com.cobblemon.mod.common.api.text.aqua
import com.cobblemon.mod.common.battles.actor.PlayerBattleActor
import com.cobblemon.mod.common.battles.dispatch.DispatchResult
import com.cobblemon.mod.common.battles.dispatch.GO
import com.cobblemon.mod.common.battles.dispatch.InterpreterInstruction
import com.cobblemon.mod.common.net.messages.client.battle.BattleInitializePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleMakeChoicePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleMusicPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleQueueRequestPacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleSetTeamPokemonPacket
import com.cobblemon.mod.common.util.battleLang
/**
@@ -36,10 +28,9 @@ class TurnInstruction(val message: BattleMessage) : InterpreterInstruction {
val turnNumber = message.argumentAt(0)?.toInt() ?: return
battle.dispatch {
battle.sendToActors(BattleMakeChoicePacket())
battle.broadcastChatMessage(battleLang("turn", turnNumber).aqua())
battle.turn(turnNumber)
GO
}
}
}
}
@@ -0,0 +1,314 @@
/*
* Copyright (C) 2023 Cobblemon Contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package com.cobblemon.mod.common.command
import com.cobblemon.mod.common.api.permission.CobblemonPermissions
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
import com.cobblemon.mod.common.api.storage.party.NPCPartyStore
import com.cobblemon.mod.common.api.text.red
import com.cobblemon.mod.common.api.npc.NPCClasses
import com.cobblemon.mod.common.CobblemonNetwork.sendPacket
import com.cobblemon.mod.common.battles.BattleBuilder
import com.cobblemon.mod.common.battles.BattleFormat
import com.cobblemon.mod.common.battles.BattleRegistry
import com.cobblemon.mod.common.battles.BattleRules
import com.cobblemon.mod.common.battles.BattleSide
import com.cobblemon.mod.common.battles.BattleStartError
import com.cobblemon.mod.common.battles.BattleStartResult
import com.cobblemon.mod.common.battles.ErroredBattleStart
import com.cobblemon.mod.common.entity.npc.NPCBattleActor
import com.cobblemon.mod.common.entity.npc.NPCEntity
import com.cobblemon.mod.common.net.messages.client.battle.BattleInitializePacket
import com.cobblemon.mod.common.net.messages.client.battle.BattleMessagePacket
import com.cobblemon.mod.common.util.requiresWithPermission
import com.cobblemon.mod.common.util.isInBattle
import com.cobblemon.mod.common.util.toBlockPos
import com.cobblemon.mod.common.util.update
import com.mojang.brigadier.Command
import com.mojang.brigadier.CommandDispatcher
import com.mojang.brigadier.arguments.BoolArgumentType
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.context.CommandContext
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands.argument
import net.minecraft.commands.Commands.literal
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.level.Level
import net.minecraft.world.phys.Vec3
object NPCBattleDebugCommand {
private const val NAME = "npcbattledebug"
private const val SINGLES = "singles"
private const val DOUBLES = "doubles"
private const val SPAWN_TWO = "spawn_two"
private const val LEVEL = "level"
private const val TEAM_SIZE = "team_size"
private const val DEFAULT_LEVEL = 50
private const val DEFAULT_TEAM_SIZE = 6
private const val NPC_DISTANCE = 8.0
private val INVALID_POS_EXCEPTION = SimpleCommandExceptionType(Component.literal("Invalid position").red())
private val FAILED_SPAWN_EXCEPTION = SimpleCommandExceptionType(Component.literal("Unable to spawn at the given position").red())
private val NO_FULLY_EVOLVED_SPECIES_EXCEPTION = SimpleCommandExceptionType(Component.literal("No fully evolved species were found in the loaded species registry.").red())
fun register(dispatcher: CommandDispatcher<CommandSourceStack>) {
dispatcher.register(
literal(NAME)
.requiresWithPermission(CobblemonPermissions.NPC_BATTLE_DEBUG) { it.player != null }
.then(formatNode(SINGLES, BattleFormat.GEN_9_SINGLES))
.then(formatNode(DOUBLES, BattleFormat.GEN_9_DOUBLES))
)
}
private fun formatNode(
formatName: String,
format: BattleFormat
) = literal(formatName)
.executes { context ->
execute(
context = context,
format = format,
spawnTwo = false,
level = DEFAULT_LEVEL,
teamSize = DEFAULT_TEAM_SIZE
)
}
.then(argument(SPAWN_TWO, BoolArgumentType.bool())
.executes { context ->
execute(
context = context,
format = format,
spawnTwo = BoolArgumentType.getBool(context, SPAWN_TWO),
level = DEFAULT_LEVEL,
teamSize = DEFAULT_TEAM_SIZE
)
}
.then(argument(LEVEL, IntegerArgumentType.integer(1, 100))
.executes { context ->
execute(
context = context,
format = format,
spawnTwo = BoolArgumentType.getBool(context, SPAWN_TWO),
level = IntegerArgumentType.getInteger(context, LEVEL),
teamSize = DEFAULT_TEAM_SIZE
)
}
.then(argument(TEAM_SIZE, IntegerArgumentType.integer(1, 6))
.executes { context ->
execute(
context = context,
format = format,
spawnTwo = BoolArgumentType.getBool(context, SPAWN_TWO),
level = IntegerArgumentType.getInteger(context, LEVEL),
teamSize = IntegerArgumentType.getInteger(context, TEAM_SIZE)
)
}
)
)
)
private fun execute(
context: CommandContext<CommandSourceStack>,
format: BattleFormat,
spawnTwo: Boolean,
level: Int,
teamSize: Int
): Int {
val effectiveFormat = format.copy(ruleSet = format.ruleSet - BattleRules.TEAM_PREVIEW)
val player = context.source.playerOrException
val requiredSlots = effectiveFormat.battleType.slotsPerActor
if (teamSize < requiredSlots) {
context.source.sendFailure(Component.literal("team_size must be at least $requiredSlots for $effectiveFormat format."))
return 0
}
val fullyEvolvedSpecies = PokemonSpecies.implemented.filter { it.standardForm.evolutions.isEmpty() }
if (fullyEvolvedSpecies.isEmpty()) {
throw NO_FULLY_EVOLVED_SPECIES_EXCEPTION.create()
}
return if (spawnTwo) {
executeNpcVsNpc(context, player, effectiveFormat, level, teamSize, fullyEvolvedSpecies)
} else {
executeNpcVsPlayer(context, player, effectiveFormat, level, teamSize, fullyEvolvedSpecies)
}
}
private fun executeNpcVsPlayer(
context: CommandContext<CommandSourceStack>,
player: ServerPlayer,
format: BattleFormat,
level: Int,
teamSize: Int,
fullyEvolvedSpecies: List<com.cobblemon.mod.common.pokemon.Species>
): Int {
val world = context.source.level
val frontPos = player.position().add(horizontalLook(player).scale(NPC_DISTANCE))
val npc = spawnDebugNpc(world, frontPos, level, teamSize, fullyEvolvedSpecies, "AI Man")
val result = BattleBuilder.pvn(
player = player,
npcEntity = npc,
battleFormat = format,
cloneParties = true,
healFirst = true
)
result.ifErrored { errors ->
errors.sendTo(player)
}
result.ifSuccessful {
context.source.sendSuccess(
{ Component.literal("Spawned 1 NPC at +$NPC_DISTANCE blocks and started a $format battle.") },
true
)
}
return Command.SINGLE_SUCCESS
}
private fun executeNpcVsNpc(
context: CommandContext<CommandSourceStack>,
player: ServerPlayer,
format: BattleFormat,
level: Int,
teamSize: Int,
fullyEvolvedSpecies: List<com.cobblemon.mod.common.pokemon.Species>
): Int {
val world = context.source.level
val look = horizontalLook(player)
val frontPos = player.position().add(look.scale(NPC_DISTANCE))
val backPos = player.position().add(look.scale(-NPC_DISTANCE))
val npc1 = spawnDebugNpc(world, frontPos, level, teamSize, fullyEvolvedSpecies, "AI Man")
val npc2 = spawnDebugNpc(world, backPos, level, teamSize, fullyEvolvedSpecies, "AI Woman")
val result = startNpcVsNpcBattle(npc1, npc2, format)
result.ifErrored { errors ->
errors.sendTo(player)
}
result.ifSuccessful { battle ->
startSpectatingBattle(player, battle)
context.source.sendSuccess(
{ Component.literal("Spawned 2 NPCs at +/-$NPC_DISTANCE blocks, started a $format NPC battle, and put you into spectator view.") },
true
)
}
return Command.SINGLE_SUCCESS
}
private fun spawnDebugNpc(
world: Level,
position: Vec3,
level: Int,
teamSize: Int,
fullyEvolvedSpecies: List<com.cobblemon.mod.common.pokemon.Species>,
name: String
): NPCEntity {
val blockPos = position.toBlockPos()
if (!Level.isInSpawnableBounds(blockPos)) {
throw INVALID_POS_EXCEPTION.create()
}
val npc = NPCEntity(world)
npc.moveTo(position.x, position.y, position.z, npc.yRot, npc.xRot)
npc.npc = NPCClasses.getByName("standard") ?: NPCClasses.classes.first()
npc.initialize(level)
npc.customName = Component.literal(name)
npc.skill = 5
npc.party = buildRandomParty(npc, level, teamSize, fullyEvolvedSpecies)
npc.party?.initialize()
if (!world.addFreshEntity(npc)) {
throw FAILED_SPAWN_EXCEPTION.create()
}
return npc
}
private fun buildRandomParty(
npc: NPCEntity,
level: Int,
teamSize: Int,
fullyEvolvedSpecies: List<com.cobblemon.mod.common.pokemon.Species>
): NPCPartyStore {
val party = NPCPartyStore(npc)
val chosenSpecies = if (fullyEvolvedSpecies.size >= teamSize) {
fullyEvolvedSpecies.shuffled().take(teamSize)
} else {
buildList {
addAll(fullyEvolvedSpecies)
repeat(teamSize - fullyEvolvedSpecies.size) {
add(fullyEvolvedSpecies.random())
}
}
}
chosenSpecies.forEachIndexed { index, species ->
val pokemon = species.create(level).also { it.heal() }
party.set(index, pokemon)
}
return party
}
private fun startNpcVsNpcBattle(
npc1: NPCEntity,
npc2: NPCEntity,
format: BattleFormat
): BattleStartResult {
val party1 = npc1.party ?: return ErroredBattleStart(mutableSetOf(BattleStartError.noParty(npc1)))
val party2 = npc2.party ?: return ErroredBattleStart(mutableSetOf(BattleStartError.noParty(npc2)))
val actor1 = NPCBattleActor(npc1, party1, npc1.skill ?: npc1.npc.skill)
val actor2 = NPCBattleActor(npc2, party2, npc2.skill ?: npc2.npc.skill)
val errors = ErroredBattleStart()
val requiredSlots = format.battleType.slotsPerActor
if (actor1.pokemonList.filter { it.health > 0 }.size < requiredSlots) {
errors.participantErrors[actor1] += BattleStartError.insufficientPokemon(npc1, requiredSlots, actor1.pokemonList.size)
}
if (actor2.pokemonList.filter { it.health > 0 }.size < requiredSlots) {
errors.participantErrors[actor2] += BattleStartError.insufficientPokemon(npc2, requiredSlots, actor2.pokemonList.size)
}
if (!errors.isEmpty) {
return errors
}
return BattleRegistry.startBattle(
battleFormat = format,
side1 = BattleSide(actor1),
side2 = BattleSide(actor2)
).ifSuccessful { battle ->
npc1.entityData.update(NPCEntity.BATTLE_IDS) { it + battle.battleId }
npc2.entityData.update(NPCEntity.BATTLE_IDS) { it + battle.battleId }
}
}
private fun horizontalLook(player: ServerPlayer): Vec3 {
val look = player.lookAngle
val horizontal = Vec3(look.x, 0.0, look.z)
if (horizontal.lengthSqr() < 1.0E-6) {
return Vec3(0.0, 0.0, 1.0)
}
return horizontal.normalize()
}
private fun startSpectatingBattle(
player: ServerPlayer,
battle: com.cobblemon.mod.common.api.battles.model.PokemonBattle
) {
if (player.isInBattle()) {
return
}
battle.spectators.add(player.uuid)
player.sendPacket(BattleInitializePacket(battle, null))
player.sendPacket(BattleMessagePacket(battle.chatLog))
}
}
@@ -53,6 +53,7 @@ import com.cobblemon.mod.common.api.spawning.CobblemonSpawnRules
import com.cobblemon.mod.common.api.spawning.SpawnDetailPresets
import com.cobblemon.mod.common.api.tms.TechnicalMachines
import com.cobblemon.mod.common.battles.BagItems
import com.cobblemon.mod.common.battles.ai.strongBattleAI.BattleAITags
import com.cobblemon.mod.common.platform.events.PlatformEvents
import com.cobblemon.mod.common.pokemon.SpeciesAdditions
import com.cobblemon.mod.common.pokemon.properties.PropertiesCompletionProvider
@@ -84,6 +85,7 @@ object CobblemonDataProvider : DataProvider {
this.register(Moves, reloadable = false)
this.register(CobblemonMovesetBuilders, reloadable = false)
this.register(Abilities, reloadable = false)
this.register(BattleAITags, reloadable = false)
this.register(CobblemonBehaviours, reloadable = false)
this.register(PokemonSpecies, reloadable = false)
this.register(SpeciesAdditions, reloadable = false)
@@ -15,7 +15,7 @@ import com.cobblemon.mod.common.api.battles.model.actor.EntityBackedBattleActor
import com.cobblemon.mod.common.api.battles.model.ai.BattleAI
import com.cobblemon.mod.common.api.net.NetworkPacket
import com.cobblemon.mod.common.api.storage.party.PartyStore
import com.cobblemon.mod.common.battles.ai.StrongBattleAI
import com.cobblemon.mod.common.battles.ai.NewStrongBattleAI
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
import com.cobblemon.mod.common.net.messages.client.battle.BattleEndPacket
import com.cobblemon.mod.common.util.battleLang
@@ -28,7 +28,7 @@ open class NPCBattleActor(
val npc: NPCEntity,
pokemonList: List<BattlePokemon>,
val skill: Int,
artificialDecider: BattleAI = StrongBattleAI(skill),
artificialDecider: BattleAI = NewStrongBattleAI(skill),
) : AIBattleActor(
gameId = npc.uuid,
pokemonList = pokemonList.let { if (npc.npc.randomizePartyOrder) it.shuffled() else it },
@@ -86,4 +86,4 @@ open class NPCBattleActor(
// }
// }
}
}
}
@@ -0,0 +1,6 @@
{
"tags": [
"stab_boost",
"tier_s"
]
}
@@ -0,0 +1,12 @@
{
"tags": [
"converts_normal_to_flying",
"move_tag_damage_synergy",
"offense_influence",
"tier_s",
"type_conversion"
],
"tag_multipliers": {
"type_normal": 1.2
}
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,8 @@
{
"tags": [
"suppress_weather",
"switch_interaction",
"tier_c",
"weather_interaction"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"offense_influence",
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"defense_influence",
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"switch_interaction",
"tier_s"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"blocks_opponent_priority",
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_s"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"defense_influence",
"tier_b"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"status_immunity_influence",
"tier_d"
]
}
@@ -0,0 +1,9 @@
{
"tags": [
"conditional_hp_low",
"conditional_type_boost",
"offense_influence",
"tier_b",
"type_fire"
]
}
@@ -0,0 +1,7 @@
{
"tags": [
"immune_move_tag_bullet",
"status_immunity_influence",
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,9 @@
{
"tags": [
"conditional_weather_sun",
"speed_boost",
"speed_priority_influence",
"tier_a",
"weather_interaction"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"status_immunity_influence",
"tier_b"
]
}
@@ -0,0 +1,8 @@
{
"tags": [
"suppress_weather",
"switch_interaction",
"tier_c",
"weather_interaction"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"status_immunity_influence",
"tier_s"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"reverse_stat_stage_changes",
"tier_s"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,10 @@
{
"tags": [
"move_tag_damage_synergy",
"offense_influence",
"tier_a"
],
"tag_multipliers": {
"type_dark": 1.33
}
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"blocks_opponent_priority",
"tier_b"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"offense_influence",
"tier_d"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"tier_s",
"weather_setter"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"tier_s",
"weather_setter"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"defense_influence",
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}
@@ -0,0 +1,10 @@
{
"tags": [
"move_tag_damage_synergy",
"offense_influence",
"tier_a"
],
"tag_multipliers": {
"type_dragon": 1.5
}
}
@@ -0,0 +1,6 @@
{
"tags": [
"tier_s",
"weather_setter"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"tier_s",
"weather_setter"
]
}
@@ -0,0 +1,8 @@
{
"tags": [
"immune_type_water",
"status_immunity_influence",
"tier_a",
"weather_interaction"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_c"
]
}
@@ -0,0 +1,7 @@
{
"tags": [
"immune_type_ground",
"status_immunity_influence",
"tier_a"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_b"
]
}
@@ -0,0 +1,6 @@
{
"tags": [
"terrain_setter",
"tier_s"
]
}
@@ -0,0 +1,5 @@
{
"tags": [
"tier_a"
]
}

Some files were not shown because too many files have changed in this diff Show More