AI worker build roads improvement (#11615)

* Reworked how city connection is calculated

* Fixed neighboringCities being saved

* Improved the A* road finding to work!

* Fixed railroad upgrading incorrectly

* Workers will now try to swap with lazy units blocking them

* Improved support for military worker units

* Worker checks if they can swap with the unit before trying to swap
This commit is contained in:
Oskar Niesen 2024-05-21 11:52:31 -05:00 committed by GitHub
parent ca1a2816c8
commit 783c0aa7c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 257 additions and 170 deletions

View File

@ -6,11 +6,14 @@ import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.BFS
import com.unciv.logic.map.HexMath
import com.unciv.logic.map.MapPathing
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.utils.Log
import com.unciv.utils.debug
import kotlin.math.max
private object WorkerAutomationConst {
/** BFS max size is determined by the aerial distance of two cities to connect, padded with this */
@ -57,21 +60,122 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
result
}
/** Cache of roads to connect cities each turn */
internal val roadsToConnectCitiesCache: HashMap<City, List<Tile>> = HashMap()
/** Cache of roads to connect cities each turn. Call [getRoadsToBuildFromCity] instead of using this */
private val roadsToBuildByCitiesCache: HashMap<City, List<RoadPlan>> = HashMap()
/** Hashmap of all cached tiles in each list in [roadsToConnectCitiesCache] */
internal val tilesOfRoadsToConnectCities: HashMap<Tile, City> = HashMap()
/** Hashmap of all cached tiles in each list in [roadsToBuildByCitiesCache] */
internal val tilesOfRoadsMap: HashMap<Tile, RoadPlan> = HashMap()
inner class RoadPlan(val tiles: List<Tile>, val priority: Float, val fromCity: City, val toCity: City) {
val numberOfRoadsToBuild: Int by lazy { tiles.count { it.getUnpillagedRoad() != bestRoadAvailable } }
}
/**
* Uses a cache to find and return the connection to make that is associated with a city.
* May not work if the unit that originally created this cache is different from the next.
* (Due to the difference in [UnitMovement.canPassThrough()])
* Tries to return a list of road plans to connect this city to the surrounding cities.
* If there are no surrounding cities to connect to and this city is still unconnected to the capital it will try and build a special road to the capital.
*
* @return every road that we want to try and connect assosiated with this city.
*/
private fun getRoadConnectionBetweenCities(unit: MapUnit, city: City): List<Tile> {
if (city in roadsToConnectCitiesCache) return roadsToConnectCitiesCache[city]!!
fun getRoadsToBuildFromCity(city: City): List<RoadPlan> {
if (roadsToBuildByCitiesCache.containsKey(city)) {
return roadsToBuildByCitiesCache[city]!!
}
// TODO: some better worker representative needs to be used here
val workerUnit = civInfo.gameInfo.ruleset.units.map { it.value }.firstOrNull { it.hasUnique(UniqueType.BuildImprovements) }?.getMapUnit(civInfo) ?: return listOf()
val roadToCapitalStatus = city.cityStats.getRoadTypeOfConnectionToCapital()
fun rankRoadCapitalPriority(roadStatus: RoadStatus): Float {
return when(roadStatus) {
RoadStatus.None -> if (bestRoadAvailable != RoadStatus.None) 2f else 0f
RoadStatus.Road -> if (bestRoadAvailable != RoadStatus.Road) 1f else 0f
else -> 0f
}
}
val basePriority = rankRoadCapitalPriority(roadToCapitalStatus)
val roadsToBuild: MutableList<RoadPlan> = mutableListOf()
for (closeCity in city.neighboringCities.filter { it.civ == civInfo }) {
// Try to find if the other city has planned to build a road to this city
if (roadsToBuildByCitiesCache.containsKey(closeCity)) {
// There should only ever be one or zero possible connections from their city to this city
val roadToBuild = roadsToBuildByCitiesCache[closeCity]!!.firstOrNull { it.fromCity == city || it.toCity == city}
if (roadToBuild != null) {
roadsToBuild.add(roadToBuild)
}
// We already did the hard work, there can't be any other possible roads to this city
continue
}
// Try to build a plan for the road to the city
val roadPath = if (civInfo.cities.indexOf(city) < civInfo.cities.indexOf(closeCity)) MapPathing.getRoadPath(workerUnit, city.getCenterTile(), closeCity.getCenterTile()) ?: continue
else MapPathing.getRoadPath(workerUnit, closeCity.getCenterTile(), city.getCenterTile()) ?: continue
val worstRoadStatus = getWorstRoadTypeInPath(roadPath)
if (worstRoadStatus == bestRoadAvailable) continue
// Make sure that we are taking in to account the other cities needs
var roadPriority = max(basePriority, rankRoadCapitalPriority(closeCity.cityStats.getRoadTypeOfConnectionToCapital()))
if (worstRoadStatus == RoadStatus.None) {
roadPriority += 2
} else if (worstRoadStatus == RoadStatus.Road && bestRoadAvailable == RoadStatus.Railroad) {
roadPriority += 1
}
if (closeCity.cityStats.getRoadTypeOfConnectionToCapital() > roadToCapitalStatus)
roadPriority += 1
val newRoadPlan = RoadPlan(roadPath, roadPriority + (city.population.population + closeCity.population.population) / 4f, city, closeCity)
roadsToBuild.add(newRoadPlan)
for (tile in newRoadPlan.tiles) {
if (tile !in tilesOfRoadsMap || tilesOfRoadsMap[tile]!!.priority < newRoadPlan.priority)
tilesOfRoadsMap[tile] = newRoadPlan
}
}
// If and only if we have no roads to build to close-by cities then we check for a road to build to the capital
if (roadsToBuild.isEmpty() && roadToCapitalStatus < bestRoadAvailable) {
val roadToCapital = getRoadToConnectCityToCapital(workerUnit, city)
if (roadToCapital != null) {
val worstRoadStatus = getWorstRoadTypeInPath(roadToCapital.second)
var roadPriority = basePriority
roadPriority += if (worstRoadStatus == RoadStatus.None) 2f else 1f
val newRoadPlan = RoadPlan(roadToCapital.second, roadPriority + (city.population.population) / 2f, city, roadToCapital.first)
roadsToBuild.add(newRoadPlan)
for (tile in newRoadPlan.tiles) {
if (tile !in tilesOfRoadsMap || tilesOfRoadsMap[tile]!!.priority < newRoadPlan.priority)
tilesOfRoadsMap[tile] = newRoadPlan
}
}
}
workerUnit.destroy()
roadsToBuildByCitiesCache[city] = roadsToBuild
return roadsToBuild
}
private fun getWorstRoadTypeInPath(path: List<Tile>): RoadStatus {
var worstRoadTile = RoadStatus.Railroad
for (tile in path) {
if (tile.getUnpillagedRoad() < worstRoadTile) {
worstRoadTile = tile.getUnpillagedRoad()
if (worstRoadTile == RoadStatus.None)
return RoadStatus.None
}
}
return worstRoadTile
}
/**
* Returns a road that can connect this city to the capital.
* This is a very expensive function that doesn't nessesarily produce the same roads as in [getRoadsToBuildFromCity].
* So it should only be used if it is the only road that a city wants to build.
* @return a pair containing a list of tiles that resemble the road to build and the city that the road will connect to
*/
private fun getRoadToConnectCityToCapital(unit: MapUnit, city: City): Pair<City, List<Tile>>? {
val isCandidateTilePredicate: (Tile) -> Boolean = { it.isLand && unit.movement.canPassThrough(it) }
val toConnectTile = city.getCenterTile()
val bfs: BFS = bfsCache[toConnectTile.position] ?:
@ -90,38 +194,12 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
// We have a winner!
val cityTile = nextTile
val pathToCity = bfs.getPathTo(cityTile)
roadsToConnectCitiesCache[city] = pathToCity.toList().filter { it.roadStatus != bestRoadAvailable }
for (tile in pathToCity) {
if (tile !in tilesOfRoadsToConnectCities)
tilesOfRoadsToConnectCities[tile] = city
}
return roadsToConnectCitiesCache[city]!!
return Pair(cityTile.getCity()!!, pathToCity.toList())
}
nextTile = bfs.nextStep()
}
roadsToConnectCitiesCache[city] = listOf()
return roadsToConnectCitiesCache[city]!!
}
/** Civ-wide list of unconnected Cities, sorted by closest to capital first */
private val citiesThatNeedConnecting: List<City> by lazy {
val result = civInfo.cities.asSequence()
.filter {
civInfo.getCapital() != null
&& it.population.population > 3
&& !it.isCapital() && !it.isBeingRazed // Cities being razed should not be connected.
&& !it.cityStats.isConnectedToCapital(bestRoadAvailable)
}.sortedBy {
it.getCenterTile().aerialDistanceTo(civInfo.getCapital()!!.getCenterTile())
}.toList()
if (Log.shouldLog()) {
debug("WorkerAutomation citiesThatNeedConnecting for ${civInfo.civName} turn $cachedForTurn:")
if (result.isEmpty()) debug("\tempty")
else result.forEach { debug("\t${it.name}") }
}
result
return null
}
/**
@ -129,17 +207,13 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
* Returns a list of all the cities close by that this worker may want to connect
*/
internal fun getNearbyCitiesToConnect(unit: MapUnit): List<City> {
if (bestRoadAvailable == RoadStatus.None || citiesThatNeedConnecting.isEmpty()) return listOf()
val candidateCities = citiesThatNeedConnecting.filter {
if (bestRoadAvailable == RoadStatus.None) return listOf()
val candidateCities = civInfo.cities.filter {
// Cities that are too far away make the canReach() calculations devastatingly long
it.getCenterTile().aerialDistanceTo(unit.getTile()) < 20
it.getCenterTile().aerialDistanceTo(unit.getTile()) < 20 && getRoadsToBuildFromCity(it).isNotEmpty()
}
if (candidateCities.none()) return listOf() // do nothing.
// Search through ALL candidate cities to build the cache
for (toConnectCity in candidateCities) {
getRoadConnectionBetweenCities(unit, toConnectCity).filter { it.getUnpillagedRoad() < bestRoadAvailable }
}
return candidateCities
}
@ -148,35 +222,30 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
* @return whether we actually did anything
*/
internal fun tryConnectingCities(unit: MapUnit, candidateCities: List<City>): Boolean {
if (bestRoadAvailable == RoadStatus.None || citiesThatNeedConnecting.isEmpty()) return false
if (bestRoadAvailable == RoadStatus.None) return false
if (candidateCities.none()) return false // do nothing.
val currentTile = unit.getTile()
var bestTileToConstructRoadOn: Tile? = null
var bestTileToConstructRoadOnDist: Int = Int.MAX_VALUE
// Search through ALL candidate cities for the closest tile to build a road on
for (toConnectCity in candidateCities) {
val roadableTiles = getRoadConnectionBetweenCities(unit, toConnectCity).filter { it.getUnpillagedRoad() < bestRoadAvailable }
val reachableTile = roadableTiles.map { Pair(it, it.aerialDistanceTo(unit.getTile())) }
.filter { it.second < bestTileToConstructRoadOnDist }
.sortedBy { it.second }
.firstOrNull {
for (toConnectCity in candidateCities.sortedByDescending { it.getCenterTile().aerialDistanceTo(unit.getTile()) }) {
val tilesByPriority = getRoadsToBuildFromCity(toConnectCity).flatMap { roadPlan -> roadPlan.tiles.map { tile -> Pair(tile, roadPlan.priority) } }
val tilesSorted = tilesByPriority.filter { it.first.getUnpillagedRoad() < bestRoadAvailable }
.sortedBy { it.first.aerialDistanceTo(unit.getTile()) + (it.second / 10f) }
val bestTile = tilesSorted.firstOrNull {
unit.movement.canMoveTo(it.first) && unit.movement.canReach(it.first)
} ?: continue // Apparently we can't reach any of these tiles at all
bestTileToConstructRoadOn = reachableTile.first
bestTileToConstructRoadOnDist = reachableTile.second
}
}?.first ?: continue // Apparently we can't reach any of these tiles at all
if (bestTileToConstructRoadOn == null) return false
if (bestTileToConstructRoadOn != currentTile && unit.currentMovement > 0)
unit.movement.headTowards(bestTileToConstructRoadOn)
if (unit.currentMovement > 0 && bestTileToConstructRoadOn == currentTile
if (bestTile != currentTile && unit.currentMovement > 0)
unit.movement.headTowards(bestTile)
if (unit.currentMovement > 0 && bestTile == currentTile
&& currentTile.improvementInProgress != bestRoadAvailable.name) {
val improvement = bestRoadAvailable.improvement(civInfo.gameInfo.ruleset)!!
bestTileToConstructRoadOn.startWorkingOnImprovement(improvement, civInfo, unit)
bestTile.startWorkingOnImprovement(improvement, civInfo, unit)
}
return true
}
return false
}
}

View File

@ -83,6 +83,12 @@ class WorkerAutomation(
if (tileToWork != currentTile) {
debug("WorkerAutomation: %s -> head towards %s", unit.toString(), tileToWork)
if (unit.movement.canReachInCurrentTurn(tileToWork) && unit.movement.canMoveTo(tileToWork, canSwap = true)) {
if (!unit.movement.canMoveTo(tileToWork, canSwap = false) && unit.movement.canUnitSwapTo(tileToWork)) {
// There must be a unit on the target tile! Lets swap with it.
unit.movement.swapMoveToTile(tileToWork)
}
}
val reachedTile = unit.movement.headTowards(tileToWork)
if (reachedTile != currentTile) unit.doAction() // otherwise, we get a situation where the worker is automated, so it tries to move but doesn't, then tries to automate, then move, etc, forever. Stack overflow exception!
@ -136,7 +142,7 @@ class WorkerAutomation(
val citiesToNumberOfUnimprovedTiles = HashMap<String, Int>()
for (city in unit.civ.cities) {
citiesToNumberOfUnimprovedTiles[city.id] = city.getTiles()
.count { it.isLand && it.civilianUnit == null && (it.isPillaged() || tileHasWorkToDo(it, unit)) }
.count { tile -> tile.isLand && tile.getUnits().any { unit -> unit.cache.hasUniqueToBuildImprovements } && (tile.isPillaged() || tileHasWorkToDo(tile, unit)) }
}
val closestUndevelopedCity = unit.civ.cities.asSequence()
@ -176,7 +182,8 @@ class WorkerAutomation(
val workableTilesCenterFirst = currentTile.getTilesInDistance(4)
.filter {
it !in tilesToAvoid
&& (it.civilianUnit == null || it == currentTile)
&& (it == currentTile || (unit.isCivilian() && (it.civilianUnit == null || !it.civilianUnit!!.cache.hasUniqueToBuildImprovements))
|| (unit.isMilitary() && (it.militaryUnit == null || !it.militaryUnit!!.cache.hasUniqueToBuildImprovements)))
&& (it.owningCity == null || it.getOwner() == civInfo)
&& !it.isCityCenter()
&& getBasePriority(it, unit) > 1
@ -194,7 +201,7 @@ class WorkerAutomation(
// These are the expensive calculations (tileCanBeImproved, canReach), so we only apply these filters after everything else it done.
if (!tileHasWorkToDo(tileInGroup, unit)) continue
if (unit.getTile() == tileInGroup) return unit.getTile()
if (!unit.movement.canReach(tileInGroup) || tileInGroup.civilianUnit != null) continue
if (!unit.movement.canReach(tileInGroup)) continue
if (bestTile == null || getFullPriority(tileInGroup, unit) > getFullPriority(bestTile, unit)) {
bestTile = tileInGroup
}
@ -233,7 +240,7 @@ class WorkerAutomation(
&& !civInfo.hasResource(tile.resource!!))
priority += 2
}
if (tile in roadBetweenCitiesAutomation.tilesOfRoadsToConnectCities) priority += when {
if (tile in roadBetweenCitiesAutomation.tilesOfRoadsMap) priority += when {
civInfo.stats.statsForNextTurn.gold <= 5 -> 0
civInfo.stats.statsForNextTurn.gold <= 10 -> 1
civInfo.stats.statsForNextTurn.gold <= 30 -> 2
@ -373,14 +380,12 @@ class WorkerAutomation(
// Add the value of roads if we want to build it here
if (improvement.isRoad() && roadBetweenCitiesAutomation.bestRoadAvailable.improvement(ruleSet) == improvement
&& tile in roadBetweenCitiesAutomation.tilesOfRoadsToConnectCities) {
var value = 1f
val city = roadBetweenCitiesAutomation.tilesOfRoadsToConnectCities[tile]!!
&& tile in roadBetweenCitiesAutomation.tilesOfRoadsMap) {
val roadPlan = roadBetweenCitiesAutomation.tilesOfRoadsMap[tile]!!
var value = roadPlan.priority
if (civInfo.stats.statsForNextTurn.gold >= 20)
// Bigger cities have a higher priority to connect
value += (city.population.population - 3) * .3f
// Higher priority if we are closer to connecting the city
value += (5 - roadBetweenCitiesAutomation.roadsToConnectCitiesCache[city]!!.size).coerceAtLeast(0)
value += (5 - roadPlan.numberOfRoadsToBuild).coerceAtLeast(0)
return value
}

View File

@ -86,6 +86,11 @@ class City : IsPartOfGameInfoSerialization {
var isPuppet = false
var updateCitizens = false // flag so that on startTurn() the Governor reassigns Citizens
@delegate:Transient
val neighboringCities: List<City> by lazy {
civ.gameInfo.getCities().filter { it != this && it.getCenterTile().aerialDistanceTo(getCenterTile()) <= 8 }.toList()
}
var cityAIFocus: String = CityFocus.NoFocus.name
fun getCityFocus() = CityFocus.values().firstOrNull { it.name == cityAIFocus } ?: CityFocus.NoFocus
fun setCityFocus(cityFocus: CityFocus){ cityAIFocus = cityFocus.name }

View File

@ -322,6 +322,12 @@ class CityStats(val city: City) {
else city.isConnectedToCapital()
}
fun getRoadTypeOfConnectionToCapital(): RoadStatus {
return if (isConnectedToCapital(RoadStatus.Railroad)) RoadStatus.Railroad
else if (isConnectedToCapital(RoadStatus.Road)) RoadStatus.Road
else RoadStatus.None
}
private fun getBuildingMaintenanceCosts(): Float {
// Same here - will have a different UI display.
var buildingsMaintenance = city.cityConstructions.getMaintenanceCosts().toFloat() // this is AFTER the bonus calculation!

View File

@ -16,30 +16,23 @@ object MapPathing {
private fun roadPreferredMovementCost(unit: MapUnit, from: Tile, to: Tile): Float{
// hasRoadConnection accounts for civs that treat jungle/forest as roads
// Ignore road over river penalties.
val areConnectedByRoad = from.hasRoadConnection(unit.civ, mustBeUnpillaged = false) && to.hasRoadConnection(unit.civ, mustBeUnpillaged = false)
if (areConnectedByRoad){
// If the civ has railroad technology, consider roads as railroads since they will be upgraded
if (unit.civ.tech.getBestRoadAvailable() == RoadStatus.Railroad){
return RoadStatus.Railroad.movement
} else {
return unit.civ.tech.movementSpeedOnRoads
}
}
val areConnectedByRailroad = from.hasRailroadConnection(mustBeUnpillaged = false) && to.hasRailroadConnection(mustBeUnpillaged = false)
if (areConnectedByRailroad)
return RoadStatus.Railroad.movement
if ((to.hasRoadConnection(unit.civ, false) || to.hasRailroadConnection(false)))
return .5f
return 1f
}
fun isValidRoadPathTile(unit: MapUnit, tile: Tile): Boolean {
val roadImprovement = tile.ruleset.roadImprovement ?: return false
val railRoadImprovement = tile.ruleset.railroadImprovement ?: return false
return tile.isLand
&& !tile.isImpassible()
&& unit.civ.hasExplored(tile)
&& tile.canCivPassThrough(unit.civ)
&& (tile.hasRoadConnection(unit.civ, true) || tile.improvementFunctions.canBuildImprovement(roadImprovement, unit.civ))
&& (tile.hasRoadConnection(unit.civ, false)
|| tile.hasRailroadConnection(false)
|| tile.improvementFunctions.canBuildImprovement(roadImprovement, unit.civ))
|| tile.improvementFunctions.canBuildImprovement(railRoadImprovement, unit.civ)
}
/**

View File

@ -61,6 +61,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
// Connect roads implies automated is true. It is specified by the action type.
var action: String? = null
var automated: Boolean = false
// We can infer who we are escorting based on our tile
var escorting: Boolean = false
@ -138,6 +139,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
class UnitMovementMemory(position: Vector2, val type: UnitMovementMemoryType) : IsPartOfGameInfoSerialization {
@Suppress("unused") // needed because this is part of a save and gets deserialized
constructor() : this(Vector2.Zero, UnitMovementMemoryType.UnitMoved)
val position = Vector2(position)
fun clone() = UnitMovementMemory(position, type)
@ -411,6 +413,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
hasUnique(UniqueType.HealOnlyByPillaging, checkCivInfoUniques = true) -> 0
else -> rankTileForHealing(getTile())
}
fun canHealInCurrentTile() = getHealAmountForCurrentTile() > 0
/** Returns the health points [MapUnit] will receive if healing on [tile] */
@ -585,6 +588,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
fun getOtherEscortUnit(): MapUnit? {
if (!::currentTile.isInitialized) return null // In some cases we might not have the unit placed on the map yet
if (isCivilian()) return getTile().militaryUnit
if (isMilitary()) return getTile().civilianUnit
return null
@ -763,11 +767,12 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun destroy(destroyTransportedUnit: Boolean = true) {
stopEscorting()
currentMovement = 0f
civ.units.removeUnit(this)
if (::currentTile.isInitialized) {
val currentPosition = Vector2(getTile().position)
civ.attacksSinceTurnStart.addAll(attacksSinceTurnStart.asSequence().map { Civilization.HistoricalAttackMemory(this.name, currentPosition, it) })
currentMovement = 0f
removeFromTile()
civ.units.removeUnit(this)
civ.cache.updateViewableTiles()
if (destroyTransportedUnit) {
// all transported units should be destroyed as well
@ -775,6 +780,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
.toList() // because we're changing the list
.forEach { unit -> unit.destroy() }
}
}
isDestroyed = true
}
@ -871,6 +878,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
when {
!movement.canMoveTo(tile) ->
throw Exception("Unit $name of ${civ.civName} at $currentTile can't be put in tile $tile!")
baseUnit.movesLikeAirUnits() -> tile.airUnits.add(this)
isCivilian() -> tile.civilianUnit = this
else -> tile.militaryUnit = this
@ -894,6 +902,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun stopEscorting() {
getOtherEscortUnit()?.escorting = false
escorting = false
if (::currentTile.isInitialized) // Can cause an error if the unit has not been placed on the map yet.
movement.clearPathfindingCache()
}