Improved AI chooseAttackTarget (#9979)

* Improved AI chooseAttackTarget

* Added extra import statement

* Restructured chooseAttackTarget to use .maxByOrNull

* Refactored getUnitAttackValue

* Standardised attack valuing in BattleHelper
This commit is contained in:
Oskar Niesen 2023-08-28 02:43:26 -05:00 committed by GitHub
parent ef8deffe67
commit 88741f95e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -3,10 +3,13 @@ package com.unciv.logic.automation.unit
import com.unciv.logic.battle.AttackableTile import com.unciv.logic.battle.AttackableTile
import com.unciv.logic.battle.Battle import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.BattleDamage import com.unciv.logic.battle.BattleDamage
import com.unciv.logic.battle.CityCombatant
import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.city.City
import com.unciv.logic.battle.TargetHelper import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unique.UniqueType
import kotlin.math.max
object BattleHelper { object BattleHelper {
@ -52,64 +55,95 @@ object BattleHelper {
return false return false
} }
/**
* Choses the best target in attackableEnemies, this could be a city or a unit.
*/
private fun chooseAttackTarget(unit: MapUnit, attackableEnemies: List<AttackableTile>): AttackableTile? { private fun chooseAttackTarget(unit: MapUnit, attackableEnemies: List<AttackableTile>): AttackableTile? {
val cityTilesToAttack = attackableEnemies.filter { it.tileToAttack.isCityCenter() } var highestAttackValue = 0
val nonCityTilesToAttack = attackableEnemies.filter { !it.tileToAttack.isCityCenter() } 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 // todo For air units, prefer to attack tiles with lower intercept chance
return if (highestAttackValue > 30) attackTile else null
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
} }
private fun chooseUnitToAttack(unit: MapUnit, attackableUnits: List<AttackableTile>): 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 var attackValue = 100
if (militaryUnits.isNotEmpty()) { // Siege units should really only attack the city
// associate enemy units with number of hits from this unit to kill them if (attacker.baseUnit.isProbablySiegeUnit()) attackValue += 100
val attacksToKill = militaryUnits // Ranged units don't take damage from the city
.associateWith { it.tileToAttack.militaryUnit!!.health.toFloat() / BattleDamage.calculateDamageToDefender( else if (attacker.baseUnit.isRanged()) attackValue += 10
MapUnitCombatant(unit), // Lower health cities have a higher priority to attack ranges from -20 to 30
MapUnitCombatant(it.tileToAttack.militaryUnit!!) attackValue -= (city.health - 60) / 2
).toFloat().coerceAtLeast(1f) }
// kill a unit if possible, prioritizing by attack strength // Add value based on number of units around the city
val canKill = attacksToKill.filter { it.value <= 1 }.keys val defendingCityCiv = city.civ
.sortedByDescending { it.movementLeftAfterMovingToAttackTile } // Among equal kills, prioritize the closest unit city.getCenterTile().neighbors.forEach {
.maxByOrNull { MapUnitCombatant(it.tileToAttack.militaryUnit!!).getAttackingStrength() } if (it.militaryUnit != null) {
if (canKill != null) return canKill if (it.militaryUnit!!.civ.isAtWarWith(attacker.civ))
attackValue -= 5
// otherwise pick the unit we can kill the fastest if (it.militaryUnit!!.civ.isAtWarWith(defendingCityCiv))
return attacksToKill.minBy { it.value }.key attackValue += 15
}
} }
return attackValue
}
// only civilians in attacking range - GP most important, second settlers, then anything else /**
* Returns a value which represents the attacker's motivation to attack a unit.
val unitsToConsider = attackableUnits.filter { it.tileToAttack.civilianUnit!!.isGreatPerson() } * Base value is 100 and standard deviation is around 80 to 130
.ifEmpty { attackableUnits.filter { it.tileToAttack.civilianUnit!!.hasUnique(UniqueType.FoundCity) } } */
.ifEmpty { attackableUnits } private fun getUnitAttackValue(attacker: MapUnit, attackTile: AttackableTile): Int {
// Base attack value, there is nothing there...
// Melee - prioritize by distance, so we have most movement left var attackValue = Int.MIN_VALUE
if (unit.baseUnit.isMelee()){ // Prioritize attacking military
return unitsToConsider.maxBy { it.movementLeftAfterMovingToAttackTile } val militaryUnit = attackTile.tileToAttack.militaryUnit
} val civilianUnit = attackTile.tileToAttack.civilianUnit
if (militaryUnit != null) {
// We're ranged, prioritize that we can kill attackValue = 100
return unitsToConsider.minBy { // Associate enemy units with number of hits from this unit to kill them
Battle.getMapCombatantOfTile(it.tileToAttack)!!.getHealth() 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
} }
} }