From 88741f95e961dae9cac77aba6934df46bae27bc4 Mon Sep 17 00:00:00 2001 From: Oskar Niesen Date: Mon, 28 Aug 2023 02:43:26 -0500 Subject: [PATCH] Improved AI chooseAttackTarget (#9979) * Improved AI chooseAttackTarget * Added extra import statement * Restructured chooseAttackTarget to use .maxByOrNull * Refactored getUnitAttackValue * Standardised attack valuing in BattleHelper --- .../logic/automation/unit/BattleHelper.kt | 134 +++++++++++------- 1 file changed, 84 insertions(+), 50 deletions(-) diff --git a/core/src/com/unciv/logic/automation/unit/BattleHelper.kt b/core/src/com/unciv/logic/automation/unit/BattleHelper.kt index 74e3f63d35..f75e4fedee 100644 --- a/core/src/com/unciv/logic/automation/unit/BattleHelper.kt +++ b/core/src/com/unciv/logic/automation/unit/BattleHelper.kt @@ -3,10 +3,13 @@ package com.unciv.logic.automation.unit import com.unciv.logic.battle.AttackableTile import com.unciv.logic.battle.Battle import com.unciv.logic.battle.BattleDamage +import com.unciv.logic.battle.CityCombatant import com.unciv.logic.battle.MapUnitCombatant +import com.unciv.logic.city.City import com.unciv.logic.battle.TargetHelper import com.unciv.logic.map.mapunit.MapUnit import com.unciv.models.ruleset.unique.UniqueType +import kotlin.math.max object BattleHelper { @@ -52,64 +55,95 @@ object BattleHelper { return false } + /** + * Choses the best target in attackableEnemies, this could be a city or a unit. + */ private fun chooseAttackTarget(unit: MapUnit, attackableEnemies: List): AttackableTile? { - val cityTilesToAttack = attackableEnemies.filter { it.tileToAttack.isCityCenter() } - val nonCityTilesToAttack = attackableEnemies.filter { !it.tileToAttack.isCityCenter() } - + var highestAttackValue = 0 + val attackTile = attackableEnemies.maxByOrNull { attackableEnemy -> + val tempAttackValue = if (attackableEnemy.tileToAttack.isCityCenter()) + getCityAttackValue(unit, attackableEnemy.tileToAttack.getCity()!!) + else getUnitAttackValue(unit, attackableEnemy) + highestAttackValue = max(tempAttackValue, highestAttackValue) + tempAttackValue + } // todo For air units, prefer to attack tiles with lower intercept chance - - val capturableCity = cityTilesToAttack.firstOrNull { it.tileToAttack.getCity()!!.health == 1 } - val cityWithHealthLeft = - cityTilesToAttack.filter { it.tileToAttack.getCity()!!.health != 1 } // don't want ranged units to attack defeated cities - .minByOrNull { it.tileToAttack.getCity()!!.health } - - if (unit.baseUnit.isMelee() && capturableCity != null) - return capturableCity // enter it quickly, top priority! - - if (nonCityTilesToAttack.isNotEmpty()) // second priority, units - return chooseUnitToAttack(unit, nonCityTilesToAttack) - - if (cityWithHealthLeft != null) return cityWithHealthLeft // third priority, city - - return null + return if (highestAttackValue > 30) attackTile else null } - private fun chooseUnitToAttack(unit: MapUnit, attackableUnits: List): AttackableTile { - val militaryUnits = attackableUnits.filter { it.tileToAttack.militaryUnit != null } + /** + * Returns a value which represents the attacker's motivation to attack a city. + * Siege units will almost always attack cities. + * Base value is 100(Mele) 110(Ranged) standard deviation is around 80 to 130 + */ + private fun getCityAttackValue(attacker: MapUnit, city: City): Int { + val attackerUnit = MapUnitCombatant(attacker) + val cityUnit = CityCombatant(city) + val isCityCapturable = city.health == 1 + || attacker.baseUnit.isMelee() && city.health <= BattleDamage.calculateDamageToDefender(attackerUnit, cityUnit).coerceAtLeast(1) + if (isCityCapturable) + return if (attacker.baseUnit.isMelee()) 10000 // Capture the city immediatly! + else 0 // Don't attack the city anymore since we are a ranged unit + + if (attacker.baseUnit.isMelee() && attacker.health - BattleDamage.calculateDamageToAttacker(attackerUnit, cityUnit) * 2 <= 0) + return 0 // We'll probably die next turn if we attack the city - // prioritize attacking military - if (militaryUnits.isNotEmpty()) { - // associate enemy units with number of hits from this unit to kill them - val attacksToKill = militaryUnits - .associateWith { it.tileToAttack.militaryUnit!!.health.toFloat() / BattleDamage.calculateDamageToDefender( - MapUnitCombatant(unit), - MapUnitCombatant(it.tileToAttack.militaryUnit!!) - ).toFloat().coerceAtLeast(1f) } + var attackValue = 100 + // Siege units should really only attack the city + if (attacker.baseUnit.isProbablySiegeUnit()) attackValue += 100 + // Ranged units don't take damage from the city + else if (attacker.baseUnit.isRanged()) attackValue += 10 + // Lower health cities have a higher priority to attack ranges from -20 to 30 + attackValue -= (city.health - 60) / 2 - // kill a unit if possible, prioritizing by attack strength - val canKill = attacksToKill.filter { it.value <= 1 }.keys - .sortedByDescending { it.movementLeftAfterMovingToAttackTile } // Among equal kills, prioritize the closest unit - .maxByOrNull { MapUnitCombatant(it.tileToAttack.militaryUnit!!).getAttackingStrength() } - if (canKill != null) return canKill - - // otherwise pick the unit we can kill the fastest - return attacksToKill.minBy { it.value }.key + // Add value based on number of units around the city + val defendingCityCiv = city.civ + city.getCenterTile().neighbors.forEach { + if (it.militaryUnit != null) { + if (it.militaryUnit!!.civ.isAtWarWith(attacker.civ)) + attackValue -= 5 + if (it.militaryUnit!!.civ.isAtWarWith(defendingCityCiv)) + attackValue += 15 + } } + + return attackValue + } - // only civilians in attacking range - GP most important, second settlers, then anything else - - val unitsToConsider = attackableUnits.filter { it.tileToAttack.civilianUnit!!.isGreatPerson() } - .ifEmpty { attackableUnits.filter { it.tileToAttack.civilianUnit!!.hasUnique(UniqueType.FoundCity) } } - .ifEmpty { attackableUnits } - - // Melee - prioritize by distance, so we have most movement left - if (unit.baseUnit.isMelee()){ - return unitsToConsider.maxBy { it.movementLeftAfterMovingToAttackTile } - } - - // We're ranged, prioritize that we can kill - return unitsToConsider.minBy { - Battle.getMapCombatantOfTile(it.tileToAttack)!!.getHealth() + /** + * Returns a value which represents the attacker's motivation to attack a unit. + * Base value is 100 and standard deviation is around 80 to 130 + */ + private fun getUnitAttackValue(attacker: MapUnit, attackTile: AttackableTile): Int { + // Base attack value, there is nothing there... + var attackValue = Int.MIN_VALUE + // Prioritize attacking military + val militaryUnit = attackTile.tileToAttack.militaryUnit + val civilianUnit = attackTile.tileToAttack.civilianUnit + if (militaryUnit != null) { + attackValue = 100 + // Associate enemy units with number of hits from this unit to kill them + val attacksToKill = (militaryUnit.health.toFloat() / + BattleDamage.calculateDamageToDefender(MapUnitCombatant(attacker), MapUnitCombatant(militaryUnit))).coerceAtLeast(1f) + // We can kill them in this turn + if (attacksToKill <= 1) attackValue += 30 + // On average, this should take around 3 turns, so -15 + else attackValue -= (attacksToKill * 5).toInt() + } else if (civilianUnit != null) { + attackValue = 50 + // Only melee units should really attack/capture civilian units, ranged units take more than one turn + if (attacker.baseUnit.isMelee()) { + if (civilianUnit.isGreatPerson()) { + attackValue += 150 + } + if (civilianUnit.hasUnique(UniqueType.FoundCity)) attackValue += 60 + } } + // Prioritise closer units as they are generally more threatening to this unit + // Moving around less means we are straying less into enemy territory + // Average should be around 2.5-5 early game and up to 35 for tanks in late game + attackValue += (attackTile.movementLeftAfterMovingToAttackTile * 5).toInt() + + return attackValue } }