From 783c0aa7c270fb7400227667e82e88b80603411d Mon Sep 17 00:00:00 2001 From: Oskar Niesen Date: Tue, 21 May 2024 11:52:31 -0500 Subject: [PATCH] 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 --- .../unit/RoadBetweenCitiesAutomation.kt | 203 ++++++++++++------ .../logic/automation/unit/WorkerAutomation.kt | 25 ++- core/src/com/unciv/logic/city/City.kt | 5 + core/src/com/unciv/logic/city/CityStats.kt | 6 + core/src/com/unciv/logic/map/MapPathing.kt | 21 +- .../com/unciv/logic/map/mapunit/MapUnit.kt | 167 +++++++------- 6 files changed, 257 insertions(+), 170 deletions(-) diff --git a/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt b/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt index 31d0cc3bed..bcc3db3ad1 100644 --- a/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/RoadBetweenCitiesAutomation.kt @@ -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> = HashMap() + /** Cache of roads to connect cities each turn. Call [getRoadsToBuildFromCity] instead of using this */ + private val roadsToBuildByCitiesCache: HashMap> = HashMap() - /** Hashmap of all cached tiles in each list in [roadsToConnectCitiesCache] */ - internal val tilesOfRoadsToConnectCities: HashMap = HashMap() + /** Hashmap of all cached tiles in each list in [roadsToBuildByCitiesCache] */ + internal val tilesOfRoadsMap: HashMap = HashMap() + + inner class RoadPlan(val tiles: List, 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 { - if (city in roadsToConnectCitiesCache) return roadsToConnectCitiesCache[city]!! + fun getRoadsToBuildFromCity(city: City): List { + 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 = 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): 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>? { 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 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 { - 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): 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 (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)!! + bestTile.startWorkingOnImprovement(improvement, civInfo, unit) + } + return true } - if (bestTileToConstructRoadOn == null) return false - - if (bestTileToConstructRoadOn != currentTile && unit.currentMovement > 0) - unit.movement.headTowards(bestTileToConstructRoadOn) - if (unit.currentMovement > 0 && bestTileToConstructRoadOn == currentTile - && currentTile.improvementInProgress != bestRoadAvailable.name) { - val improvement = bestRoadAvailable.improvement(civInfo.gameInfo.ruleset)!! - bestTileToConstructRoadOn.startWorkingOnImprovement(improvement, civInfo, unit) - } - return true + return false } } diff --git a/core/src/com/unciv/logic/automation/unit/WorkerAutomation.kt b/core/src/com/unciv/logic/automation/unit/WorkerAutomation.kt index 5789471f05..b945888db0 100644 --- a/core/src/com/unciv/logic/automation/unit/WorkerAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/WorkerAutomation.kt @@ -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() 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 } diff --git a/core/src/com/unciv/logic/city/City.kt b/core/src/com/unciv/logic/city/City.kt index f06d456afc..d6228e5fc4 100644 --- a/core/src/com/unciv/logic/city/City.kt +++ b/core/src/com/unciv/logic/city/City.kt @@ -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 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 } diff --git a/core/src/com/unciv/logic/city/CityStats.kt b/core/src/com/unciv/logic/city/CityStats.kt index c0b014cd9f..b2f52e0a60 100644 --- a/core/src/com/unciv/logic/city/CityStats.kt +++ b/core/src/com/unciv/logic/city/CityStats.kt @@ -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! diff --git a/core/src/com/unciv/logic/map/MapPathing.kt b/core/src/com/unciv/logic/map/MapPathing.kt index 24934b08da..bc9ba4bd55 100644 --- a/core/src/com/unciv/logic/map/MapPathing.kt +++ b/core/src/com/unciv/logic/map/MapPathing.kt @@ -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) } /** diff --git a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt index ceeacd536e..a246bbe158 100644 --- a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt +++ b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt @@ -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 @@ -137,7 +138,8 @@ 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) + constructor() : this(Vector2.Zero, UnitMovementMemoryType.UnitMoved) + val position = Vector2(position) fun clone() = UnitMovementMemory(position, type) @@ -157,8 +159,8 @@ class MapUnit : IsPartOfGameInfoSerialization { */ fun displayName(): String { val baseName = - if (instanceName == null) "[$name]" - else "$instanceName ([$name])" + if (instanceName == null) "[$name]" + else "$instanceName ([$name])" return if (religion == null) baseName else "$baseName ([${getReligionDisplayName()}])" @@ -201,7 +203,7 @@ class MapUnit : IsPartOfGameInfoSerialization { get() = baseUnit.type fun getMovementString(): String = - DecimalFormat("0.#").format(currentMovement.toDouble()) + "/" + getMaxMovement() + DecimalFormat("0.#").format(currentMovement.toDouble()) + "/" + getMaxMovement() fun getTile(): Tile = currentTile @@ -247,8 +249,8 @@ class MapUnit : IsPartOfGameInfoSerialization { if (currentMovement == 0f) return false val tile = getTile() if (tile.improvementInProgress != null && - canBuildImprovement(tile.getTileImprovementInProgress()!!) && - !tile.isMarkedForCreatesOneImprovement() + canBuildImprovement(tile.getTileImprovementInProgress()!!) && + !tile.isMarkedForCreatesOneImprovement() ) return false if (includeOtherEscortUnit && isEscorting() && !getOtherEscortUnit()!!.isIdle(false)) return false return !(isFortified() || isExploring() || isSleeping() || isAutomated() || isMoving()) @@ -257,32 +259,32 @@ class MapUnit : IsPartOfGameInfoSerialization { fun getUniques(): Sequence = tempUniquesMap.values.asSequence().flatten() fun getMatchingUniques( - uniqueType: UniqueType, - stateForConditionals: StateForConditionals = StateForConditionals(civ, unit=this), - checkCivInfoUniques: Boolean = false + uniqueType: UniqueType, + stateForConditionals: StateForConditionals = StateForConditionals(civ, unit = this), + checkCivInfoUniques: Boolean = false ) = sequence { - yieldAll( + yieldAll( tempUniquesMap.getMatchingUniques(uniqueType, stateForConditionals) - ) + ) if (checkCivInfoUniques) yieldAll(civ.getMatchingUniques(uniqueType, stateForConditionals)) } fun hasUnique( - uniqueType: UniqueType, - stateForConditionals: StateForConditionals = StateForConditionals(civ, unit=this), - checkCivInfoUniques: Boolean = false + uniqueType: UniqueType, + stateForConditionals: StateForConditionals = StateForConditionals(civ, unit = this), + checkCivInfoUniques: Boolean = false ): Boolean { return getMatchingUniques(uniqueType, stateForConditionals, checkCivInfoUniques).any() } fun getTriggeredUniques( - trigger: UniqueType, - stateForConditionals: StateForConditionals = StateForConditionals(civInfo = civ, unit = this) + trigger: UniqueType, + stateForConditionals: StateForConditionals = StateForConditionals(civInfo = civ, unit = this) ): Sequence { return getUniques().filter { unique -> unique.conditionals.any { it.type == trigger } - && unique.conditionalsApply(stateForConditionals) + && unique.conditionalsApply(stateForConditionals) } } @@ -306,11 +308,11 @@ class MapUnit : IsPartOfGameInfoSerialization { fun getMaxMovement(): Int { var movement = - if (isEmbarked()) 2 - else baseUnit.movement + if (isEmbarked()) 2 + else baseUnit.movement movement += getMatchingUniques(UniqueType.Movement, checkCivInfoUniques = true) - .sumOf { it.params[0].toInt() } + .sumOf { it.params[0].toInt() } if (movement < 1) movement = 1 @@ -320,7 +322,7 @@ class MapUnit : IsPartOfGameInfoSerialization { for (boostingUnit in currentTile.getUnits()) { if (boostingUnit == this) continue if (boostingUnit.getMatchingUniques(UniqueType.TransferMovement) - .none { matchesFilter(it.params[0]) } ) continue + .none { matchesFilter(it.params[0]) }) continue movement = movement.coerceAtLeast(boostingUnit.getMaxMovement()) } @@ -352,7 +354,7 @@ class MapUnit : IsPartOfGameInfoSerialization { fun maxAttacksPerTurn(): Int { return 1 + getMatchingUniques(UniqueType.AdditionalAttacks, checkCivInfoUniques = true) - .sumOf { it.params[0].toInt() } + .sumOf { it.params[0].toInt() } } fun canAttack(): Boolean { @@ -365,7 +367,7 @@ class MapUnit : IsPartOfGameInfoSerialization { if (baseUnit.isMelee()) return 1 var range = baseUnit.range range += getMatchingUniques(UniqueType.Range, checkCivInfoUniques = true) - .sumOf { it.params[0].toInt() } + .sumOf { it.params[0].toInt() } return range } @@ -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] */ @@ -445,8 +448,8 @@ class MapUnit : IsPartOfGameInfoSerialization { } val maxAdjacentHealingBonus = currentTile.neighbors - .flatMap { it.getUnits() }.filter { it.civ == civ } - .map { it.adjacentHealingBonus() }.maxOrNull() + .flatMap { it.getUnits() }.filter { it.civ == civ } + .map { it.adjacentHealingBonus() }.maxOrNull() if (maxAdjacentHealingBonus != null) healing += maxAdjacentHealingBonus @@ -467,7 +470,7 @@ class MapUnit : IsPartOfGameInfoSerialization { fun getInterceptionRange(): Int { val rangeFromUniques = getMatchingUniques(UniqueType.AirInterceptionRange, checkCivInfoUniques = true) - .sumOf { it.params[0].toInt() } + .sumOf { it.params[0].toInt() } return baseUnit.interceptRange + rangeFromUniques } @@ -476,8 +479,8 @@ class MapUnit : IsPartOfGameInfoSerialization { // Air Units can only Intercept if they didn't move this turn if (baseUnit.isAirUnit() && currentMovement == 0f) return false val maxAttacksPerTurn = 1 + - getMatchingUniques(UniqueType.ExtraInterceptionsPerTurn) - .sumOf { it.params[0].toInt() } + getMatchingUniques(UniqueType.ExtraInterceptionsPerTurn) + .sumOf { it.params[0].toInt() } if (attacksThisTurn >= maxAttacksPerTurn) return false return true } @@ -488,7 +491,7 @@ class MapUnit : IsPartOfGameInfoSerialization { fun interceptDamagePercentBonus(): Int { return getMatchingUniques(UniqueType.DamageWhenIntercepting) - .sumOf { it.params[0].toInt() } + .sumOf { it.params[0].toInt() } } fun receivedInterceptDamageFactor(): Float { @@ -499,7 +502,7 @@ class MapUnit : IsPartOfGameInfoSerialization { } fun getDamageFromTerrain(tile: Tile = currentTile): Int { - return tile.allTerrains.sumOf { it.damagePerTurn } + return tile.allTerrains.sumOf { it.damagePerTurn } } fun isTransportTypeOf(mapUnit: MapUnit): Boolean { @@ -510,9 +513,9 @@ class MapUnit : IsPartOfGameInfoSerialization { private fun carryCapacity(unit: MapUnit): Int { return (getMatchingUniques(UniqueType.CarryAirUnits) - + getMatchingUniques(UniqueType.CarryExtraAirUnits)) - .filter { unit.matchesFilter(it.params[1]) } - .sumOf { it.params[0].toInt() } + + getMatchingUniques(UniqueType.CarryExtraAirUnits)) + .filter { unit.matchesFilter(it.params[1]) } + .sumOf { it.params[0].toInt() } } fun canTransport(unit: MapUnit): Boolean { @@ -525,13 +528,13 @@ class MapUnit : IsPartOfGameInfoSerialization { /** Gets a Nuke's blast radius from the BlastRadius unique, defaulting to 2. No check whether the unit actually is a Nuke. */ fun getNukeBlastRadius() = getMatchingUniques(UniqueType.BlastRadius) - // Don't check conditionals as these are not supported - .firstOrNull()?.params?.get(0)?.toInt() ?: 2 + // Don't check conditionals as these are not supported + .firstOrNull()?.params?.get(0)?.toInt() ?: 2 private fun isAlly(otherCiv: Civilization): Boolean { return otherCiv == civ - || (otherCiv.isCityState() && otherCiv.getAllyCiv() == civ.civName) - || (civ.isCityState() && civ.getAllyCiv() == otherCiv.civName) + || (otherCiv.isCityState() && otherCiv.getAllyCiv() == civ.civName) + || (civ.isCityState() && civ.getAllyCiv() == otherCiv.civName) } /** Implements [UniqueParameterType.MapUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.MapUnitFilter] */ @@ -559,8 +562,8 @@ class MapUnit : IsPartOfGameInfoSerialization { // Workers (and similar) should never be able to (instantly) construct things, only build them // HOWEVER, they should be able to repair such things if they are pillaged if (improvement.turnsToBuild == -1 - && improvement.name != Constants.cancelImprovementOrder - && tile.improvementInProgress != improvement.name + && improvement.name != Constants.cancelImprovementOrder + && tile.improvementInProgress != improvement.name ) return false val buildImprovementUniques = getMatchingUniques(UniqueType.BuildImprovements) if (tile.improvementInProgress == Constants.repair) { @@ -568,7 +571,7 @@ class MapUnit : IsPartOfGameInfoSerialization { return buildImprovementUniques.any() } return buildImprovementUniques - .any { improvement.matchesFilter(it.params[0]) || tile.matchesTerrainFilter(it.params[0]) } + .any { improvement.matchesFilter(it.params[0]) || tile.matchesTerrainFilter(it.params[0]) } } fun getReligionDisplayName(): String? { @@ -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 @@ -613,7 +617,7 @@ class MapUnit : IsPartOfGameInfoSerialization { fun setTransients(ruleset: Ruleset) { promotions.setTransients(this) baseUnit = ruleset.units[name] - ?: throw java.lang.Exception("Unit $name is not found!") + ?: throw java.lang.Exception("Unit $name is not found!") updateUniques() if (action == UnitActionType.Automate.value) automated = true @@ -621,9 +625,9 @@ class MapUnit : IsPartOfGameInfoSerialization { fun updateUniques() { val uniqueSources = - baseUnit.uniqueObjects.asSequence() + - type.uniqueObjects + - promotions.getPromotions().flatMap { it.uniqueObjects } + baseUnit.uniqueObjects.asSequence() + + type.uniqueObjects + + promotions.getPromotions().flatMap { it.uniqueObjects } tempUniquesMap = UniqueMap(uniqueSources) cache.updateUniques() } @@ -649,7 +653,7 @@ class MapUnit : IsPartOfGameInfoSerialization { /** * Update this unit's cache of viewable tiles and its civ's as well. */ - fun updateVisibleTiles(updateCivViewableTiles:Boolean = true, explorerPosition: Vector2? = null) { + fun updateVisibleTiles(updateCivViewableTiles: Boolean = true, explorerPosition: Vector2? = null) { val oldViewableTiles = viewableTiles viewableTiles = when { @@ -661,9 +665,9 @@ class MapUnit : IsPartOfGameInfoSerialization { // Set equality automatically determines if anything changed - https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-abstract-set/equals.html if (updateCivViewableTiles && oldViewableTiles != viewableTiles - // Don't bother updating if all previous and current viewable tiles are within our borders - && (oldViewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles } - || viewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles })) { + // Don't bother updating if all previous and current viewable tiles are within our borders + && (oldViewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles } + || viewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles })) { val unfilteredTriggeredUniques = getTriggeredUniques(UniqueType.TriggerUponDiscoveringTile, StateForConditionals.IgnoreConditionals).toList() if (unfilteredTriggeredUniques.isNotEmpty()) { @@ -672,12 +676,12 @@ class MapUnit : IsPartOfGameInfoSerialization { } for (tile in newlyExploredTiles) { // Include tile in the state for correct RNG seeding - val state = StateForConditionals(civInfo=civ, unit=this, tile=tile) + val state = StateForConditionals(civInfo = civ, unit = this, tile = tile) for (unique in unfilteredTriggeredUniques) { if (unique.conditionals.any { - it.type == UniqueType.TriggerUponDiscoveringTile - && tile.matchesFilter(it.params[0], civ) - } && unique.conditionalsApply(state) + it.type == UniqueType.TriggerUponDiscoveringTile + && tile.matchesFilter(it.params[0], civ) + } && unique.conditionalsApply(state) ) UniqueTriggerActivation.triggerUnique(unique, this) } @@ -712,7 +716,7 @@ class MapUnit : IsPartOfGameInfoSerialization { if (isEscorting() && getOtherEscortUnit()!!.currentMovement == 0f) return val enemyUnitsInWalkingDistance = movement.getDistanceToTiles().keys - .filter { it.militaryUnit != null && civ.isAtWarWith(it.militaryUnit!!.civ) } + .filter { it.militaryUnit != null && civ.isAtWarWith(it.militaryUnit!!.civ) } if (enemyUnitsInWalkingDistance.isNotEmpty()) { if (isMoving()) // stop on enemy in sight action = null @@ -731,7 +735,7 @@ class MapUnit : IsPartOfGameInfoSerialization { val gotTo = movement.headTowards(destinationTile) if (gotTo == currentTile) { // We didn't move at all // pathway blocked? Are we still at the same spot as start of turn? - if(movementMemories.last().position == currentTile.position) + if (movementMemories.last().position == currentTile.position) action = null return } @@ -747,8 +751,8 @@ class MapUnit : IsPartOfGameInfoSerialization { fun healBy(amount: Int) { health += amount * - if (hasUnique(UniqueType.HealingEffectsDoubled, checkCivInfoUniques = true)) 2 - else 1 + if (hasUnique(UniqueType.HealingEffectsDoubled, checkCivInfoUniques = true)) 2 + else 1 if (health > 100) health = 100 cache.updateUniques() } @@ -763,18 +767,21 @@ class MapUnit : IsPartOfGameInfoSerialization { fun destroy(destroyTransportedUnit: Boolean = true) { stopEscorting() - 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 - currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) } - .toList() // because we're changing the list - .forEach { unit -> unit.destroy() } + if (::currentTile.isInitialized) { + val currentPosition = Vector2(getTile().position) + civ.attacksSinceTurnStart.addAll(attacksSinceTurnStart.asSequence().map { Civilization.HistoricalAttackMemory(this.name, currentPosition, it) }) + removeFromTile() + civ.cache.updateViewableTiles() + if (destroyTransportedUnit) { + // all transported units should be destroyed as well + currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) } + .toList() // because we're changing the list + .forEach { unit -> unit.destroy() } + } } + isDestroyed = true } @@ -784,7 +791,7 @@ class MapUnit : IsPartOfGameInfoSerialization { civ.cache.updateViewableTiles() // all transported units should be gift as well currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) } - .forEach { unit -> unit.gift(recipient) } + .forEach { unit -> unit.gift(recipient) } assignOwner(recipient) recipient.cache.updateViewableTiles() } @@ -817,7 +824,7 @@ class MapUnit : IsPartOfGameInfoSerialization { civ.addStat(stat.key, stat.value.toInt()) civ.addNotification("By expending your [$name] you gained [${gainedStats.toStringForNotifications()}]!", - getTile().position, NotificationCategory.Units, name) + getTile().position, NotificationCategory.Units, name) } fun removeFromTile() = currentTile.removeUnit(this) @@ -855,8 +862,8 @@ class MapUnit : IsPartOfGameInfoSerialization { } val promotionUniques = tile.neighbors - .flatMap { it.allTerrains } - .flatMap { it.getMatchingUniques(UniqueType.TerrainGrantsPromotion) } + .flatMap { it.allTerrains } + .flatMap { it.getMatchingUniques(UniqueType.TerrainGrantsPromotion) } for (unique in promotionUniques) { if (!this.matchesFilter(unique.params[2])) continue val promotion = unique.params[0] @@ -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,7 +902,8 @@ class MapUnit : IsPartOfGameInfoSerialization { fun stopEscorting() { getOtherEscortUnit()?.escorting = false escorting = false - movement.clearPathfindingCache() + if (::currentTile.isInitialized) // Can cause an error if the unit has not been placed on the map yet. + movement.clearPathfindingCache() } private fun clearEncampment(tile: Tile) { @@ -902,27 +911,27 @@ class MapUnit : IsPartOfGameInfoSerialization { // Notify City-States that this unit cleared a Barbarian Encampment, required for quests civ.gameInfo.getAliveCityStates() - .forEach { it.questManager.barbarianCampCleared(civ, tile.position) } + .forEach { it.questManager.barbarianCampCleared(civ, tile.position) } var goldGained = - civ.getDifficulty().clearBarbarianCampReward * civ.gameInfo.speed.goldCostModifier + civ.getDifficulty().clearBarbarianCampReward * civ.gameInfo.speed.goldCostModifier if (civ.hasUnique(UniqueType.TripleGoldFromEncampmentsAndCities)) goldGained *= 3f civ.addGold(goldGained.toInt()) civ.addNotification( - "We have captured a barbarian encampment and recovered [${goldGained.toInt()}] gold!", - tile.position, - NotificationCategory.War, - NotificationIcon.Gold + "We have captured a barbarian encampment and recovered [${goldGained.toInt()}] gold!", + tile.position, + NotificationCategory.War, + NotificationIcon.Gold ) } fun disband() { // evacuation of transported units before disbanding, if possible. toListed because we're modifying the unit list. for (unit in currentTile.getUnits() - .filter { it.isTransported && isTransportTypeOf(it) } - .toList() + .filter { it.isTransported && isTransportTypeOf(it) } + .toList() ) { // if we disbanded a unit carrying other units in a city, the carried units can still stay in the city if (currentTile.isCityCenter() && unit.movement.canMoveTo(currentTile)) { @@ -934,7 +943,7 @@ class MapUnit : IsPartOfGameInfoSerialization { unit.disband() // let's find closest city or another carrier where it can be evacuated val tileCanMoveTo = unit.currentTile.getTilesInDistance(unit.getMaxMovementForAirUnits()) - .filterNot { it == currentTile }.firstOrNull { unit.movement.canMoveTo(it) } + .filterNot { it == currentTile }.firstOrNull { unit.movement.canMoveTo(it) } if (tileCanMoveTo != null) unit.movement.moveToTile(tileCanMoveTo)