Preparation for Tactical AI Rework: analysis map, domination zones (#8381)

* Preparations for AI Rework: tactical analysis map, tactical domination zones, city distances

* Iteration whole tilemap -> iteration 4-tile rings around cities

* Optimize iteration

Co-authored-by: tunerzinc@gmail.com <vfylfhby>
This commit is contained in:
vegeta1k95 2023-01-15 10:24:54 +01:00 committed by GitHub
parent 864145acbb
commit 280d3da933
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 547 additions and 3 deletions

View File

@ -0,0 +1,124 @@
package com.unciv.logic
import com.badlogic.gdx.math.Vector2
import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
class CityDistance(
val city: CityInfo,
val distance: Int) {
companion object {
fun compare(a: CityDistance?, b: CityDistance?) : CityDistance? {
if (a == null && b != null)
return b
else if (a != null && b == null)
return a
else if (a == null && b == null)
return null
if (a!!.distance < b!!.distance)
return a
else if (a.distance > b.distance)
return b
if (a.city.civInfo.isMajorCiv() && b.city.civInfo.isMinorCiv())
return a
else if (b.city.civInfo.isMajorCiv() && a.city.civInfo.isMinorCiv())
return b
return a
}
}
}
/** This class holds information about distance from every tile to the nearest city */
class CityDistanceData {
@Transient
lateinit var game: GameInfo
companion object {
const val IDENTIFIER_ALL_CIVS = "ALL_CIVS"
const val IDENTIFIER_MAJOR_CIVS = "MAJOR_CIVS"
}
private var shouldUpdate: Boolean = true
/** Identifier -> Map (Tile position -> Distance)
* Identifier is either: Civ name, ALL_CIVS or MAJOR_CIVS */
private var data: HashMap<String, HashMap<Vector2, CityDistance?>> = HashMap()
private fun reset() {
data = HashMap()
data[IDENTIFIER_ALL_CIVS]= HashMap()
data[IDENTIFIER_MAJOR_CIVS] = HashMap()
}
private fun resetPlayer(identifier: String) {
data[identifier] = HashMap()
}
private fun updateDistanceIfLower(identifier: String, position: Vector2, city: CityInfo, distance: Int) {
val currentDistance = data[identifier]!![position]
val newDistance = CityDistance(city, distance)
data[identifier]!![position] = CityDistance.compare(currentDistance, newDistance)
}
private fun updateDistances(thisTile: TileInfo, city: CityInfo, owner: CivilizationInfo, isMajor: Boolean) {
val cityTile = city.getCenterTile()
val distance = thisTile.aerialDistanceTo(cityTile)
updateDistanceIfLower(IDENTIFIER_ALL_CIVS, thisTile.position, city, distance)
if (isMajor) {
updateDistanceIfLower(IDENTIFIER_MAJOR_CIVS, thisTile.position, city, distance)
updateDistanceIfLower(owner.civName, thisTile.position, city, distance)
}
}
private fun update() {
// Clear previous info
reset()
for (player in game.civilizations) {
// Not interested in defeated players
if (player.isDefeated())
continue
val isMajor = player.isMajorCiv()
if (isMajor)
resetPlayer(player.civName)
// Update distances for each tile inside radius 4 around each city
for (city in player.cities.asSequence())
for (otherTile in city.getCenterTile().getTilesInDistance(4))
updateDistances(otherTile, city, player, isMajor)
}
shouldUpdate = false
}
fun getClosestCityDistance(tile: TileInfo, player: CivilizationInfo? = null, majorsOnly: Boolean = false) : CityDistance? {
if (shouldUpdate)
update()
val identifier = when {
player != null && player.isMajorCiv() -> player.civName
majorsOnly -> IDENTIFIER_MAJOR_CIVS
else -> IDENTIFIER_ALL_CIVS
}
return data[identifier]!![tile.position]
}
fun setDirty() {
shouldUpdate = true
}
}

View File

@ -139,6 +139,9 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
@Transient
var spaceResources = HashSet<String>()
@Transient
var cityDistances: CityDistanceData = CityDistanceData()
//endregion
//region Pure functions
@ -578,6 +581,8 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
barbarians.setTransients(this)
cityDistances.game = this
guaranteeUnitPromotions()
}

View File

@ -0,0 +1,57 @@
package com.unciv.logic.automation.ai
import com.badlogic.gdx.graphics.Color
import com.unciv.UncivGame
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.utils.extensions.toGroup
import com.unciv.utils.Log
class TacticalAI : IsPartOfGameInfoSerialization {
private val debug: Boolean = false
@Transient private val tacticalAnalysisMap = TacticalAnalysisMap()
@Transient private var player: CivilizationInfo? = null
fun init(player: CivilizationInfo) {
this.player = player
tacticalAnalysisMap.reset(player)
}
fun showZonesDebug(tile: TileInfo) {
if (!debug)
return
val zone = tacticalAnalysisMap.getZoneByTile(tile)
val zoneId = zone?.id
Log.debug("MYTAG Zone $zoneId City: ${zone?.city} Area: ${zone?.area} Area size: ${
tile.tileMap.continentSizes[tile.getContinent()]} Zone size: ${zone?.tileCount}")
val mapHolder = UncivGame.Current.worldScreen!!.mapHolder
for (otherTile in mapHolder.tileMap.values.asSequence()) {
val otherZoneId = tacticalAnalysisMap.plotPositionToZoneId[otherTile.position]
if (otherZoneId == zoneId) {
mapHolder.tileGroups[otherTile]?.forEach {
mapHolder.addOverlayOnTileGroup(it, ImageGetter.getCircle().apply {
color = when (zone?.territoryType) {
TacticalTerritoryType.FRIENDLY -> Color.GREEN
TacticalTerritoryType.ENEMY -> Color.RED
else -> Color.WHITE
}
}.toGroup(20f)) }
}
if (zone?.neighboringZones?.contains(otherZoneId) == true) {
mapHolder.tileGroups[otherTile]?.forEach {
mapHolder.addOverlayOnTileGroup(it, ImageGetter.getCircle().apply { color = Color.GRAY }.toGroup(20f)) }
}
}
}
}

View File

@ -0,0 +1,347 @@
package com.unciv.logic.automation.ai
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.GameInfo
import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.TileInfo
import com.unciv.logic.map.TileMap
import com.unciv.utils.Log
import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
enum class TacticalTerritoryType {
NONE,
FRIENDLY,
ENEMY,
NEUTRAL
}
class TacticalDominanceZone {
var id = "UNKNOWN"
var territoryType = TacticalTerritoryType.NONE
var owner: CivilizationInfo? = null
var city: CityInfo? = null
var area: Int = -1
var tileCount: Int = 0
var neighboringZones: HashSet<String> = HashSet()
var friendlyMeleeStrength = 0
var friendlyRangeStrength = 0
var friendlyNavalMeleeStrength = 0
var friendlyNavalRangeStrength = 0
var friendlyUnitCount = 0
var friendlyNavalUnitCount = 0
var enemyMeleeStrength = 0
var enemyRangeStrength = 0
var enemyNavalMeleeStrength = 0
var enemyNavalRangeStrength = 0
var enemyUnitCount = 0
var enemyNavalUnitCount = 0
val neutralUnitStrength = 0
val neutralUnitCount = 0
var zoneValue = 0
fun getOverallFriendlyStrength(): Int {
return friendlyMeleeStrength*4/3 + friendlyNavalMeleeStrength + friendlyRangeStrength + friendlyNavalRangeStrength
}
fun getOverallEnemyStrength(): Int {
return enemyMeleeStrength*4/3 + enemyNavalMeleeStrength + enemyRangeStrength + enemyNavalRangeStrength
}
fun isWater(): Boolean {
return id.startsWith('-')
}
fun extend(tile: TileInfo) {
tileCount += 1
}
}
class TacticalAnalysisMap {
lateinit var game: GameInfo // Current game
lateinit var player: CivilizationInfo // Current player
var lastUpdate: Int = -1
val zones: ArrayList<TacticalDominanceZone> = ArrayList()
val zoneIdToZoneIndex: HashMap<String, Int> = HashMap()
val plotPositionToZoneId: HashMap<Vector2, String> = HashMap()
companion object {
const val maxRange = 4
const val maxZoneSize = 30
}
fun reset(player: CivilizationInfo) {
this.player = player
this.game = player.gameInfo
this.lastUpdate = -1
this.zones.clear()
this.zoneIdToZoneIndex.clear()
this.plotPositionToZoneId.clear()
}
fun isUpToDate() : Boolean {
if (lastUpdate == -1)
return false
if (player != game.currentPlayerCiv)
return true
return lastUpdate == game.turns
}
fun invalidate() {
lastUpdate = -1
}
private fun refreshIfOutdated() {
if (isUpToDate())
return
Log.debug("Refreshing Tactical Analysis Map...")
// This is where creation and separation of zones occur
createDominanceZones()
establishZoneNeighborhood()
// This is workaround for absent "Area" mechanics.
// We glue small leftovers to bigger zones
glueSmallZonesToBig()
// TODO: calculateMilitaryStrength
// TODO: prioritizeZones
// TODO: updatePostures
}
fun getZoneByTile(tile: TileInfo): TacticalDominanceZone? {
refreshIfOutdated()
val zoneId = plotPositionToZoneId[tile.position]?: return null
return getZoneById(zoneId)
}
fun getZoneById(id: String): TacticalDominanceZone? {
refreshIfOutdated()
val index = zoneIdToZoneIndex[id]
if (index != null)
return getZoneByIndex(index)
return null
}
fun getZoneByIndex(index: Int): TacticalDominanceZone? {
refreshIfOutdated()
if (index < 0 || index >= zones.size)
return null
return zones[index]
}
fun createDominanceZones() {
lastUpdate = game.turns
zones.clear()
zoneIdToZoneIndex.clear()
plotPositionToZoneId.clear()
val unknownZone = TacticalDominanceZone()
zones.add(unknownZone)
zoneIdToZoneIndex[unknownZone.id] = 0
val nonCityTiles = ArrayList<TileInfo>()
var zone: TacticalDominanceZone? = null
for (tile in game.tileMap.values.asSequence()) {
// Unexplored plot go into their own zone
if (!player.hasExplored(tile)) {
plotPositionToZoneId[tile.position] = unknownZone.id
continue
}
// Is this plot close to a city?
val cityDistance = game.cityDistances.getClosestCityDistance(tile, null, false)
if (cityDistance == null) {
// Non-city tiles processed separately
nonCityTiles.add(tile)
continue
}
val city: CityInfo? = when {
cityDistance.distance < 3 -> cityDistance.city
else -> tile.getCity()
}
if (city == null) {
nonCityTiles.add(tile)
continue
}
val zoneId = if (tile.isWater) "-${city.id}" else city.id
// Chances are it's the same zone as before
if (zone == null || zone.id != zoneId) {
zone = getZoneById(zoneId)
// Still not found? Create new
if (zone == null) {
val newZone = TacticalDominanceZone()
newZone.id = zoneId
newZone.city = city
newZone.owner = city.civInfo
newZone.area = tile.getContinent()
if (newZone.owner == player)
newZone.territoryType = TacticalTerritoryType.FRIENDLY
else if (newZone.owner?.isAtWarWith(player) == true)
newZone.territoryType = TacticalTerritoryType.ENEMY
else
newZone.territoryType = TacticalTerritoryType.NEUTRAL
zoneIdToZoneIndex[zoneId] = zones.size
zones.add(newZone)
zone = zones.last()
}
}
plotPositionToZoneId[tile.position] = zoneId
zone.extend(tile)
}
// Ensure that continents sizes are calculated
game.tileMap.assignContinents(TileMap.AssignContinentsMode.Ensure)
while (nonCityTiles.isNotEmpty()) {
var count = maxZoneSize
val stack: ArrayList<TileInfo> = ArrayList()
stack.add(nonCityTiles.removeFirst())
val randomId = UUID.randomUUID().toString()
val newId = if (stack.last().isWater) "-$randomId" else randomId
val newZone = TacticalDominanceZone()
newZone.id = newId
newZone.city = null
newZone.area = stack.last().getContinent()
newZone.territoryType = TacticalTerritoryType.NEUTRAL
while (stack.isNotEmpty() || count > 0) {
val tile = stack.removeLastOrNull() ?: break
val tileContinentSize = tile.tileMap.continentSizes[tile.getContinent()] ?: Int.MAX_VALUE
plotPositionToZoneId[tile.position] = newId
newZone.extend(tile)
for (neighbor in tile.neighbors) {
// We don't want lakes and mountains to be separate zones - should attach them too
val isLake = neighbor.matchesTerrainFilter(Constants.lakes)
val isMountain = neighbor.matchesTerrainFilter(Constants.mountain)
val neighborContinentSize = neighbor.tileMap.continentSizes[neighbor.getContinent()] ?: Int.MAX_VALUE
val isSameZone = neighbor.getContinent() == tile.getContinent()
|| isLake || (isMountain && neighbor.isLand)
|| neighborContinentSize < 4 || tileContinentSize < 4
if (isSameZone && nonCityTiles.contains(neighbor) && count > 0) {
nonCityTiles.remove(neighbor)
stack.add(neighbor)
count -= 1
}
}
}
zoneIdToZoneIndex[newZone.id] = zones.size
zones.add(newZone)
}
}
private fun glueSmallZonesToBig() {
val toRemove = HashSet<TacticalDominanceZone>()
for (zone in zones) {
if (zone.tileCount < 5 && zone.city == null) {
val biggerZone = zones.asSequence()
.filter { it.isWater() == zone.isWater() && it.neighboringZones.contains(zone.id) }
.firstOrNull()
if (biggerZone != null) {
plotPositionToZoneId.asSequence()
.filter { it.value == zone.id }
.forEach { plotPositionToZoneId[it.key] = biggerZone.id }
toRemove.add(zone)
}
}
}
zones.removeAll(toRemove)
zoneIdToZoneIndex.clear()
for (i in 0 until zones.size) {
val zoneId = zones[i].id
zoneIdToZoneIndex[zoneId] = i
}
}
private fun establishZoneNeighborhood() {
for (zone in zones)
zone.neighboringZones.clear()
val tileMatrix = game.tileMap.tileMatrix
val gridH = tileMatrix.size-1
val gridW = tileMatrix.size-1
for (y in 0 until gridH) {
for (x in 0 until gridW) {
val tileA = tileMatrix[y][x]
val tileB = tileMatrix[y+1][x]
val tileC = tileMatrix[y][x+1]
val tileD = tileMatrix[y+1][x+1]
val zoneA = if (tileA == null) getZoneById("UNKNOWN") else getZoneByTile(tileA)
val zoneB = if (tileB == null) getZoneById("UNKNOWN") else getZoneByTile(tileB)
val zoneC = if (tileC == null) getZoneById("UNKNOWN") else getZoneByTile(tileC)
val zoneD = if (tileD == null) getZoneById("UNKNOWN") else getZoneByTile(tileD)
val zoneAId = zoneA!!.id
val zoneBId = zoneB!!.id
val zoneCId = zoneC!!.id
val zoneDId = zoneD!!.id
if (zoneAId != "UNKNOWN" && zoneBId != "UNKNOWN" && zoneAId != zoneBId) {
zoneA.neighboringZones.add(zoneB.id)
zoneB.neighboringZones.add(zoneA.id)
}
if (zoneAId != "UNKNOWN" && zoneCId != "UNKNOWN" && zoneAId != zoneCId) {
zoneA.neighboringZones.add(zoneC.id)
zoneC.neighboringZones.add(zoneA.id)
}
if (zoneAId != "UNKNOWN" && zoneDId != "UNKNOWN" && zoneAId != zoneDId) {
zoneA.neighboringZones.add(zoneD.id)
zoneD.neighboringZones.add(zoneA.id)
}
}
}
}
fun debugOutput() {
Log.debug("MYTAG: Total tactical zones: ${zones.size}")
for (zone in zones) {
Log.debug("MYTAG: Zone: ${zone.id} City: ${zone.city} Territory: ${zone.territoryType} Neighbors: ${zone.neighboringZones}")
}
}
}

View File

@ -153,7 +153,7 @@ object UnitAutomation {
val tilesCanMoveTo = unit.movement.getDistanceToTiles()
.filter { unit.movement.canMoveTo(it.key) }
if (tilesCanMoveTo.isNotEmpty())
unit.movement.moveToTile(tilesCanMoveTo.minBy { it.value.totalDistance }.key)
unit.movement.moveToTile(tilesCanMoveTo.minByOrNull { it.value.totalDistance }!!.key)
}

View File

@ -218,6 +218,8 @@ class CityInfo : IsPartOfGameInfoSerialization {
}
triggerCitiesSettledNearOtherCiv()
civInfo.gameInfo.cityDistances.setDirty()
}
private fun addStartingBuildings(civInfo: CivilizationInfo, startingEra: String) {
@ -807,6 +809,8 @@ class CityInfo : IsPartOfGameInfoSerialization {
civInfo.updateProximity(otherCiv,
otherCiv.updateProximity(civInfo))
}
civInfo.gameInfo.cityDistances.setDirty()
}
fun annexCity() = CityInfoConquestFunctions(this).annexCity()

View File

@ -8,6 +8,7 @@ import com.unciv.logic.GameInfo
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.UncivShowableException
import com.unciv.logic.VictoryData
import com.unciv.logic.automation.ai.TacticalAI
import com.unciv.logic.automation.civilization.NextTurnAutomation
import com.unciv.logic.automation.unit.WorkerAutomation
import com.unciv.logic.city.CityInfo
@ -173,6 +174,9 @@ class CivilizationInfo : IsPartOfGameInfoSerialization {
private var allyCivName: String? = null
var naturalWonders = ArrayList<String>()
/* AI section */
val tacticalAI = TacticalAI()
var notifications = ArrayList<Notification>()
var notificationsLog = ArrayList<NotificationsLog>()
@ -364,6 +368,7 @@ class CivilizationInfo : IsPartOfGameInfoSerialization {
var cityStateResource: String? = null
var cityStateUniqueUnit: String? = null // Unique unit for militaristic city state. Might still be null if there are no appropriate units
fun isMajorCiv() = nation.isMajorCiv()
fun isMinorCiv() = nation.isCityState() || nation.isBarbarian()
fun isAlive(): Boolean = !isDefeated()
fun hasMetCivTerritory(otherCiv: CivilizationInfo): Boolean = otherCiv.getCivTerritory().any { hasExplored(it) }
@ -894,6 +899,8 @@ class CivilizationInfo : IsPartOfGameInfoSerialization {
hasLongCountDisplayUnique = hasUnique(UniqueType.MayanCalendarDisplay)
tacticalAI.init(this)
}
fun updateSightAndResources() {

View File

@ -218,7 +218,7 @@ class WorldMapHolder(
unitTable.citySelected(previousSelectedCity)
}
}
worldScreen.viewingCiv.tacticalAI.showZonesDebug(tileInfo)
worldScreen.shouldUpdate = true
}
@ -502,7 +502,7 @@ class WorldMapHolder(
}
private fun addOverlayOnTileGroup(group: TileGroup, actor: Actor) {
fun addOverlayOnTileGroup(group: TileGroup, actor: Actor) {
actor.center(group)
actor.x += group.x