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.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>): 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>): 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
}
}