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.civilization.Civilization
import com.unciv.logic.map.BFS import com.unciv.logic.map.BFS
import com.unciv.logic.map.HexMath import com.unciv.logic.map.HexMath
import com.unciv.logic.map.MapPathing
import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.utils.Log import com.unciv.utils.Log
import com.unciv.utils.debug import com.unciv.utils.debug
import kotlin.math.max
private object WorkerAutomationConst { private object WorkerAutomationConst {
/** BFS max size is determined by the aerial distance of two cities to connect, padded with this */ /** 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 result
} }
/** Cache of roads to connect cities each turn */ /** Cache of roads to connect cities each turn. Call [getRoadsToBuildFromCity] instead of using this */
internal val roadsToConnectCitiesCache: HashMap<City, List<Tile>> = HashMap() private val roadsToBuildByCitiesCache: HashMap<City, List<RoadPlan>> = HashMap()
/** Hashmap of all cached tiles in each list in [roadsToConnectCitiesCache] */ /** Hashmap of all cached tiles in each list in [roadsToBuildByCitiesCache] */
internal val tilesOfRoadsToConnectCities: HashMap<Tile, City> = HashMap() 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. * Tries to return a list of road plans to connect this city to the surrounding cities.
* May not work if the unit that originally created this cache is different from the next. * 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.
* (Due to the difference in [UnitMovement.canPassThrough()]) *
* @return every road that we want to try and connect assosiated with this city.
*/ */
private fun getRoadConnectionBetweenCities(unit: MapUnit, city: City): List<Tile> { fun getRoadsToBuildFromCity(city: City): List<RoadPlan> {
if (city in roadsToConnectCitiesCache) return roadsToConnectCitiesCache[city]!! 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 isCandidateTilePredicate: (Tile) -> Boolean = { it.isLand && unit.movement.canPassThrough(it) }
val toConnectTile = city.getCenterTile() val toConnectTile = city.getCenterTile()
val bfs: BFS = bfsCache[toConnectTile.position] ?: val bfs: BFS = bfsCache[toConnectTile.position] ?:
@ -90,38 +194,12 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
// We have a winner! // We have a winner!
val cityTile = nextTile val cityTile = nextTile
val pathToCity = bfs.getPathTo(cityTile) val pathToCity = bfs.getPathTo(cityTile)
roadsToConnectCitiesCache[city] = pathToCity.toList().filter { it.roadStatus != bestRoadAvailable }
for (tile in pathToCity) { return Pair(cityTile.getCity()!!, pathToCity.toList())
if (tile !in tilesOfRoadsToConnectCities)
tilesOfRoadsToConnectCities[tile] = city
}
return roadsToConnectCitiesCache[city]!!
} }
nextTile = bfs.nextStep() nextTile = bfs.nextStep()
} }
return null
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
} }
/** /**
@ -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 * Returns a list of all the cities close by that this worker may want to connect
*/ */
internal fun getNearbyCitiesToConnect(unit: MapUnit): List<City> { internal fun getNearbyCitiesToConnect(unit: MapUnit): List<City> {
if (bestRoadAvailable == RoadStatus.None || citiesThatNeedConnecting.isEmpty()) return listOf() if (bestRoadAvailable == RoadStatus.None) return listOf()
val candidateCities = citiesThatNeedConnecting.filter { val candidateCities = civInfo.cities.filter {
// Cities that are too far away make the canReach() calculations devastatingly long // 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. 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 return candidateCities
} }
@ -148,35 +222,30 @@ class RoadBetweenCitiesAutomation(val civInfo: Civilization, cachedForTurn: Int,
* @return whether we actually did anything * @return whether we actually did anything
*/ */
internal fun tryConnectingCities(unit: MapUnit, candidateCities: List<City>): Boolean { 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. if (candidateCities.none()) return false // do nothing.
val currentTile = unit.getTile() 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 // Search through ALL candidate cities for the closest tile to build a road on
for (toConnectCity in candidateCities) { for (toConnectCity in candidateCities.sortedByDescending { it.getCenterTile().aerialDistanceTo(unit.getTile()) }) {
val roadableTiles = getRoadConnectionBetweenCities(unit, toConnectCity).filter { it.getUnpillagedRoad() < bestRoadAvailable } val tilesByPriority = getRoadsToBuildFromCity(toConnectCity).flatMap { roadPlan -> roadPlan.tiles.map { tile -> Pair(tile, roadPlan.priority) } }
val reachableTile = roadableTiles.map { Pair(it, it.aerialDistanceTo(unit.getTile())) } val tilesSorted = tilesByPriority.filter { it.first.getUnpillagedRoad() < bestRoadAvailable }
.filter { it.second < bestTileToConstructRoadOnDist } .sortedBy { it.first.aerialDistanceTo(unit.getTile()) + (it.second / 10f) }
.sortedBy { it.second } val bestTile = tilesSorted.firstOrNull {
.firstOrNull {
unit.movement.canMoveTo(it.first) && unit.movement.canReach(it.first) unit.movement.canMoveTo(it.first) && unit.movement.canReach(it.first)
} ?: continue // Apparently we can't reach any of these tiles at all }?.first ?: continue // Apparently we can't reach any of these tiles at all
bestTileToConstructRoadOn = reachableTile.first
bestTileToConstructRoadOnDist = reachableTile.second 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 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
} }
} }

View File

@ -83,6 +83,12 @@ class WorkerAutomation(
if (tileToWork != currentTile) { if (tileToWork != currentTile) {
debug("WorkerAutomation: %s -> head towards %s", unit.toString(), tileToWork) 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) 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! 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>() val citiesToNumberOfUnimprovedTiles = HashMap<String, Int>()
for (city in unit.civ.cities) { for (city in unit.civ.cities) {
citiesToNumberOfUnimprovedTiles[city.id] = city.getTiles() 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() val closestUndevelopedCity = unit.civ.cities.asSequence()
@ -176,7 +182,8 @@ class WorkerAutomation(
val workableTilesCenterFirst = currentTile.getTilesInDistance(4) val workableTilesCenterFirst = currentTile.getTilesInDistance(4)
.filter { .filter {
it !in tilesToAvoid 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.owningCity == null || it.getOwner() == civInfo)
&& !it.isCityCenter() && !it.isCityCenter()
&& getBasePriority(it, unit) > 1 && 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. // These are the expensive calculations (tileCanBeImproved, canReach), so we only apply these filters after everything else it done.
if (!tileHasWorkToDo(tileInGroup, unit)) continue if (!tileHasWorkToDo(tileInGroup, unit)) continue
if (unit.getTile() == tileInGroup) return unit.getTile() 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)) { if (bestTile == null || getFullPriority(tileInGroup, unit) > getFullPriority(bestTile, unit)) {
bestTile = tileInGroup bestTile = tileInGroup
} }
@ -233,7 +240,7 @@ class WorkerAutomation(
&& !civInfo.hasResource(tile.resource!!)) && !civInfo.hasResource(tile.resource!!))
priority += 2 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 <= 5 -> 0
civInfo.stats.statsForNextTurn.gold <= 10 -> 1 civInfo.stats.statsForNextTurn.gold <= 10 -> 1
civInfo.stats.statsForNextTurn.gold <= 30 -> 2 civInfo.stats.statsForNextTurn.gold <= 30 -> 2
@ -373,14 +380,12 @@ class WorkerAutomation(
// Add the value of roads if we want to build it here // Add the value of roads if we want to build it here
if (improvement.isRoad() && roadBetweenCitiesAutomation.bestRoadAvailable.improvement(ruleSet) == improvement if (improvement.isRoad() && roadBetweenCitiesAutomation.bestRoadAvailable.improvement(ruleSet) == improvement
&& tile in roadBetweenCitiesAutomation.tilesOfRoadsToConnectCities) { && tile in roadBetweenCitiesAutomation.tilesOfRoadsMap) {
var value = 1f val roadPlan = roadBetweenCitiesAutomation.tilesOfRoadsMap[tile]!!
val city = roadBetweenCitiesAutomation.tilesOfRoadsToConnectCities[tile]!! var value = roadPlan.priority
if (civInfo.stats.statsForNextTurn.gold >= 20) 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 // 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 return value
} }

View File

@ -86,6 +86,11 @@ class City : IsPartOfGameInfoSerialization {
var isPuppet = false var isPuppet = false
var updateCitizens = false // flag so that on startTurn() the Governor reassigns Citizens 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 var cityAIFocus: String = CityFocus.NoFocus.name
fun getCityFocus() = CityFocus.values().firstOrNull { it.name == cityAIFocus } ?: CityFocus.NoFocus fun getCityFocus() = CityFocus.values().firstOrNull { it.name == cityAIFocus } ?: CityFocus.NoFocus
fun setCityFocus(cityFocus: CityFocus){ cityAIFocus = cityFocus.name } fun setCityFocus(cityFocus: CityFocus){ cityAIFocus = cityFocus.name }

View File

@ -322,6 +322,12 @@ class CityStats(val city: City) {
else city.isConnectedToCapital() 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 { private fun getBuildingMaintenanceCosts(): Float {
// Same here - will have a different UI display. // Same here - will have a different UI display.
var buildingsMaintenance = city.cityConstructions.getMaintenanceCosts().toFloat() // this is AFTER the bonus calculation! 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{ private fun roadPreferredMovementCost(unit: MapUnit, from: Tile, to: Tile): Float{
// hasRoadConnection accounts for civs that treat jungle/forest as roads // hasRoadConnection accounts for civs that treat jungle/forest as roads
// Ignore road over river penalties. // Ignore road over river penalties.
val areConnectedByRoad = from.hasRoadConnection(unit.civ, mustBeUnpillaged = false) && to.hasRoadConnection(unit.civ, mustBeUnpillaged = false) if ((to.hasRoadConnection(unit.civ, false) || to.hasRailroadConnection(false)))
if (areConnectedByRoad){ return .5f
// 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
return 1f return 1f
} }
fun isValidRoadPathTile(unit: MapUnit, tile: Tile): Boolean { fun isValidRoadPathTile(unit: MapUnit, tile: Tile): Boolean {
val roadImprovement = tile.ruleset.roadImprovement ?: return false val roadImprovement = tile.ruleset.roadImprovement ?: return false
val railRoadImprovement = tile.ruleset.railroadImprovement ?: return false
return tile.isLand return tile.isLand
&& !tile.isImpassible() && !tile.isImpassible()
&& unit.civ.hasExplored(tile) && unit.civ.hasExplored(tile)
&& tile.canCivPassThrough(unit.civ) && 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. // Connect roads implies automated is true. It is specified by the action type.
var action: String? = null var action: String? = null
var automated: Boolean = false var automated: Boolean = false
// We can infer who we are escorting based on our tile // We can infer who we are escorting based on our tile
var escorting: Boolean = false var escorting: Boolean = false
@ -137,7 +138,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
* */ * */
class UnitMovementMemory(position: Vector2, val type: UnitMovementMemoryType) : IsPartOfGameInfoSerialization { class UnitMovementMemory(position: Vector2, val type: UnitMovementMemoryType) : IsPartOfGameInfoSerialization {
@Suppress("unused") // needed because this is part of a save and gets deserialized @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) val position = Vector2(position)
fun clone() = UnitMovementMemory(position, type) fun clone() = UnitMovementMemory(position, type)
@ -157,8 +159,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
*/ */
fun displayName(): String { fun displayName(): String {
val baseName = val baseName =
if (instanceName == null) "[$name]" if (instanceName == null) "[$name]"
else "$instanceName ([$name])" else "$instanceName ([$name])"
return if (religion == null) baseName return if (religion == null) baseName
else "$baseName ([${getReligionDisplayName()}])" else "$baseName ([${getReligionDisplayName()}])"
@ -201,7 +203,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
get() = baseUnit.type get() = baseUnit.type
fun getMovementString(): String = fun getMovementString(): String =
DecimalFormat("0.#").format(currentMovement.toDouble()) + "/" + getMaxMovement() DecimalFormat("0.#").format(currentMovement.toDouble()) + "/" + getMaxMovement()
fun getTile(): Tile = currentTile fun getTile(): Tile = currentTile
@ -247,8 +249,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
if (currentMovement == 0f) return false if (currentMovement == 0f) return false
val tile = getTile() val tile = getTile()
if (tile.improvementInProgress != null && if (tile.improvementInProgress != null &&
canBuildImprovement(tile.getTileImprovementInProgress()!!) && canBuildImprovement(tile.getTileImprovementInProgress()!!) &&
!tile.isMarkedForCreatesOneImprovement() !tile.isMarkedForCreatesOneImprovement()
) return false ) return false
if (includeOtherEscortUnit && isEscorting() && !getOtherEscortUnit()!!.isIdle(false)) return false if (includeOtherEscortUnit && isEscorting() && !getOtherEscortUnit()!!.isIdle(false)) return false
return !(isFortified() || isExploring() || isSleeping() || isAutomated() || isMoving()) return !(isFortified() || isExploring() || isSleeping() || isAutomated() || isMoving())
@ -257,32 +259,32 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun getUniques(): Sequence<Unique> = tempUniquesMap.values.asSequence().flatten() fun getUniques(): Sequence<Unique> = tempUniquesMap.values.asSequence().flatten()
fun getMatchingUniques( fun getMatchingUniques(
uniqueType: UniqueType, uniqueType: UniqueType,
stateForConditionals: StateForConditionals = StateForConditionals(civ, unit=this), stateForConditionals: StateForConditionals = StateForConditionals(civ, unit = this),
checkCivInfoUniques: Boolean = false checkCivInfoUniques: Boolean = false
) = sequence { ) = sequence {
yieldAll( yieldAll(
tempUniquesMap.getMatchingUniques(uniqueType, stateForConditionals) tempUniquesMap.getMatchingUniques(uniqueType, stateForConditionals)
) )
if (checkCivInfoUniques) if (checkCivInfoUniques)
yieldAll(civ.getMatchingUniques(uniqueType, stateForConditionals)) yieldAll(civ.getMatchingUniques(uniqueType, stateForConditionals))
} }
fun hasUnique( fun hasUnique(
uniqueType: UniqueType, uniqueType: UniqueType,
stateForConditionals: StateForConditionals = StateForConditionals(civ, unit=this), stateForConditionals: StateForConditionals = StateForConditionals(civ, unit = this),
checkCivInfoUniques: Boolean = false checkCivInfoUniques: Boolean = false
): Boolean { ): Boolean {
return getMatchingUniques(uniqueType, stateForConditionals, checkCivInfoUniques).any() return getMatchingUniques(uniqueType, stateForConditionals, checkCivInfoUniques).any()
} }
fun getTriggeredUniques( fun getTriggeredUniques(
trigger: UniqueType, trigger: UniqueType,
stateForConditionals: StateForConditionals = StateForConditionals(civInfo = civ, unit = this) stateForConditionals: StateForConditionals = StateForConditionals(civInfo = civ, unit = this)
): Sequence<Unique> { ): Sequence<Unique> {
return getUniques().filter { unique -> return getUniques().filter { unique ->
unique.conditionals.any { it.type == trigger } unique.conditionals.any { it.type == trigger }
&& unique.conditionalsApply(stateForConditionals) && unique.conditionalsApply(stateForConditionals)
} }
} }
@ -306,11 +308,11 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun getMaxMovement(): Int { fun getMaxMovement(): Int {
var movement = var movement =
if (isEmbarked()) 2 if (isEmbarked()) 2
else baseUnit.movement else baseUnit.movement
movement += getMatchingUniques(UniqueType.Movement, checkCivInfoUniques = true) movement += getMatchingUniques(UniqueType.Movement, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
if (movement < 1) movement = 1 if (movement < 1) movement = 1
@ -320,7 +322,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
for (boostingUnit in currentTile.getUnits()) { for (boostingUnit in currentTile.getUnits()) {
if (boostingUnit == this) continue if (boostingUnit == this) continue
if (boostingUnit.getMatchingUniques(UniqueType.TransferMovement) if (boostingUnit.getMatchingUniques(UniqueType.TransferMovement)
.none { matchesFilter(it.params[0]) } ) continue .none { matchesFilter(it.params[0]) }) continue
movement = movement.coerceAtLeast(boostingUnit.getMaxMovement()) movement = movement.coerceAtLeast(boostingUnit.getMaxMovement())
} }
@ -352,7 +354,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun maxAttacksPerTurn(): Int { fun maxAttacksPerTurn(): Int {
return 1 + getMatchingUniques(UniqueType.AdditionalAttacks, checkCivInfoUniques = true) return 1 + getMatchingUniques(UniqueType.AdditionalAttacks, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
} }
fun canAttack(): Boolean { fun canAttack(): Boolean {
@ -365,7 +367,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
if (baseUnit.isMelee()) return 1 if (baseUnit.isMelee()) return 1
var range = baseUnit.range var range = baseUnit.range
range += getMatchingUniques(UniqueType.Range, checkCivInfoUniques = true) range += getMatchingUniques(UniqueType.Range, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
return range return range
} }
@ -411,6 +413,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
hasUnique(UniqueType.HealOnlyByPillaging, checkCivInfoUniques = true) -> 0 hasUnique(UniqueType.HealOnlyByPillaging, checkCivInfoUniques = true) -> 0
else -> rankTileForHealing(getTile()) else -> rankTileForHealing(getTile())
} }
fun canHealInCurrentTile() = getHealAmountForCurrentTile() > 0 fun canHealInCurrentTile() = getHealAmountForCurrentTile() > 0
/** Returns the health points [MapUnit] will receive if healing on [tile] */ /** Returns the health points [MapUnit] will receive if healing on [tile] */
@ -445,8 +448,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
} }
val maxAdjacentHealingBonus = currentTile.neighbors val maxAdjacentHealingBonus = currentTile.neighbors
.flatMap { it.getUnits() }.filter { it.civ == civ } .flatMap { it.getUnits() }.filter { it.civ == civ }
.map { it.adjacentHealingBonus() }.maxOrNull() .map { it.adjacentHealingBonus() }.maxOrNull()
if (maxAdjacentHealingBonus != null) if (maxAdjacentHealingBonus != null)
healing += maxAdjacentHealingBonus healing += maxAdjacentHealingBonus
@ -467,7 +470,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun getInterceptionRange(): Int { fun getInterceptionRange(): Int {
val rangeFromUniques = getMatchingUniques(UniqueType.AirInterceptionRange, checkCivInfoUniques = true) val rangeFromUniques = getMatchingUniques(UniqueType.AirInterceptionRange, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
return baseUnit.interceptRange + rangeFromUniques return baseUnit.interceptRange + rangeFromUniques
} }
@ -476,8 +479,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
// Air Units can only Intercept if they didn't move this turn // Air Units can only Intercept if they didn't move this turn
if (baseUnit.isAirUnit() && currentMovement == 0f) return false if (baseUnit.isAirUnit() && currentMovement == 0f) return false
val maxAttacksPerTurn = 1 + val maxAttacksPerTurn = 1 +
getMatchingUniques(UniqueType.ExtraInterceptionsPerTurn) getMatchingUniques(UniqueType.ExtraInterceptionsPerTurn)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
if (attacksThisTurn >= maxAttacksPerTurn) return false if (attacksThisTurn >= maxAttacksPerTurn) return false
return true return true
} }
@ -488,7 +491,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun interceptDamagePercentBonus(): Int { fun interceptDamagePercentBonus(): Int {
return getMatchingUniques(UniqueType.DamageWhenIntercepting) return getMatchingUniques(UniqueType.DamageWhenIntercepting)
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
} }
fun receivedInterceptDamageFactor(): Float { fun receivedInterceptDamageFactor(): Float {
@ -499,7 +502,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
} }
fun getDamageFromTerrain(tile: Tile = currentTile): Int { fun getDamageFromTerrain(tile: Tile = currentTile): Int {
return tile.allTerrains.sumOf { it.damagePerTurn } return tile.allTerrains.sumOf { it.damagePerTurn }
} }
fun isTransportTypeOf(mapUnit: MapUnit): Boolean { fun isTransportTypeOf(mapUnit: MapUnit): Boolean {
@ -510,9 +513,9 @@ class MapUnit : IsPartOfGameInfoSerialization {
private fun carryCapacity(unit: MapUnit): Int { private fun carryCapacity(unit: MapUnit): Int {
return (getMatchingUniques(UniqueType.CarryAirUnits) return (getMatchingUniques(UniqueType.CarryAirUnits)
+ getMatchingUniques(UniqueType.CarryExtraAirUnits)) + getMatchingUniques(UniqueType.CarryExtraAirUnits))
.filter { unit.matchesFilter(it.params[1]) } .filter { unit.matchesFilter(it.params[1]) }
.sumOf { it.params[0].toInt() } .sumOf { it.params[0].toInt() }
} }
fun canTransport(unit: MapUnit): Boolean { 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. */ /** 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) fun getNukeBlastRadius() = getMatchingUniques(UniqueType.BlastRadius)
// Don't check conditionals as these are not supported // Don't check conditionals as these are not supported
.firstOrNull()?.params?.get(0)?.toInt() ?: 2 .firstOrNull()?.params?.get(0)?.toInt() ?: 2
private fun isAlly(otherCiv: Civilization): Boolean { private fun isAlly(otherCiv: Civilization): Boolean {
return otherCiv == civ return otherCiv == civ
|| (otherCiv.isCityState() && otherCiv.getAllyCiv() == civ.civName) || (otherCiv.isCityState() && otherCiv.getAllyCiv() == civ.civName)
|| (civ.isCityState() && civ.getAllyCiv() == otherCiv.civName) || (civ.isCityState() && civ.getAllyCiv() == otherCiv.civName)
} }
/** Implements [UniqueParameterType.MapUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.MapUnitFilter] */ /** 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 // 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 // HOWEVER, they should be able to repair such things if they are pillaged
if (improvement.turnsToBuild == -1 if (improvement.turnsToBuild == -1
&& improvement.name != Constants.cancelImprovementOrder && improvement.name != Constants.cancelImprovementOrder
&& tile.improvementInProgress != improvement.name && tile.improvementInProgress != improvement.name
) return false ) return false
val buildImprovementUniques = getMatchingUniques(UniqueType.BuildImprovements) val buildImprovementUniques = getMatchingUniques(UniqueType.BuildImprovements)
if (tile.improvementInProgress == Constants.repair) { if (tile.improvementInProgress == Constants.repair) {
@ -568,7 +571,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return buildImprovementUniques.any() return buildImprovementUniques.any()
} }
return buildImprovementUniques 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? { fun getReligionDisplayName(): String? {
@ -585,6 +588,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
} }
fun getOtherEscortUnit(): MapUnit? { 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 (isCivilian()) return getTile().militaryUnit
if (isMilitary()) return getTile().civilianUnit if (isMilitary()) return getTile().civilianUnit
return null return null
@ -613,7 +617,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun setTransients(ruleset: Ruleset) { fun setTransients(ruleset: Ruleset) {
promotions.setTransients(this) promotions.setTransients(this)
baseUnit = ruleset.units[name] baseUnit = ruleset.units[name]
?: throw java.lang.Exception("Unit $name is not found!") ?: throw java.lang.Exception("Unit $name is not found!")
updateUniques() updateUniques()
if (action == UnitActionType.Automate.value) automated = true if (action == UnitActionType.Automate.value) automated = true
@ -621,9 +625,9 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun updateUniques() { fun updateUniques() {
val uniqueSources = val uniqueSources =
baseUnit.uniqueObjects.asSequence() + baseUnit.uniqueObjects.asSequence() +
type.uniqueObjects + type.uniqueObjects +
promotions.getPromotions().flatMap { it.uniqueObjects } promotions.getPromotions().flatMap { it.uniqueObjects }
tempUniquesMap = UniqueMap(uniqueSources) tempUniquesMap = UniqueMap(uniqueSources)
cache.updateUniques() cache.updateUniques()
} }
@ -649,7 +653,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
/** /**
* Update this unit's cache of viewable tiles and its civ's as well. * 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 val oldViewableTiles = viewableTiles
viewableTiles = when { 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 // Set equality automatically determines if anything changed - https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-abstract-set/equals.html
if (updateCivViewableTiles && oldViewableTiles != viewableTiles if (updateCivViewableTiles && oldViewableTiles != viewableTiles
// Don't bother updating if all previous and current viewable tiles are within our borders // Don't bother updating if all previous and current viewable tiles are within our borders
&& (oldViewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles } && (oldViewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles }
|| viewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles })) { || viewableTiles.any { it !in civ.cache.ourTilesAndNeighboringTiles })) {
val unfilteredTriggeredUniques = getTriggeredUniques(UniqueType.TriggerUponDiscoveringTile, StateForConditionals.IgnoreConditionals).toList() val unfilteredTriggeredUniques = getTriggeredUniques(UniqueType.TriggerUponDiscoveringTile, StateForConditionals.IgnoreConditionals).toList()
if (unfilteredTriggeredUniques.isNotEmpty()) { if (unfilteredTriggeredUniques.isNotEmpty()) {
@ -672,12 +676,12 @@ class MapUnit : IsPartOfGameInfoSerialization {
} }
for (tile in newlyExploredTiles) { for (tile in newlyExploredTiles) {
// Include tile in the state for correct RNG seeding // 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) { for (unique in unfilteredTriggeredUniques) {
if (unique.conditionals.any { if (unique.conditionals.any {
it.type == UniqueType.TriggerUponDiscoveringTile it.type == UniqueType.TriggerUponDiscoveringTile
&& tile.matchesFilter(it.params[0], civ) && tile.matchesFilter(it.params[0], civ)
} && unique.conditionalsApply(state) } && unique.conditionalsApply(state)
) )
UniqueTriggerActivation.triggerUnique(unique, this) UniqueTriggerActivation.triggerUnique(unique, this)
} }
@ -712,7 +716,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
if (isEscorting() && getOtherEscortUnit()!!.currentMovement == 0f) return if (isEscorting() && getOtherEscortUnit()!!.currentMovement == 0f) return
val enemyUnitsInWalkingDistance = movement.getDistanceToTiles().keys 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 (enemyUnitsInWalkingDistance.isNotEmpty()) {
if (isMoving()) // stop on enemy in sight if (isMoving()) // stop on enemy in sight
action = null action = null
@ -731,7 +735,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
val gotTo = movement.headTowards(destinationTile) val gotTo = movement.headTowards(destinationTile)
if (gotTo == currentTile) { // We didn't move at all if (gotTo == currentTile) { // We didn't move at all
// pathway blocked? Are we still at the same spot as start of turn? // 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 action = null
return return
} }
@ -747,8 +751,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun healBy(amount: Int) { fun healBy(amount: Int) {
health += amount * health += amount *
if (hasUnique(UniqueType.HealingEffectsDoubled, checkCivInfoUniques = true)) 2 if (hasUnique(UniqueType.HealingEffectsDoubled, checkCivInfoUniques = true)) 2
else 1 else 1
if (health > 100) health = 100 if (health > 100) health = 100
cache.updateUniques() cache.updateUniques()
} }
@ -763,18 +767,21 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun destroy(destroyTransportedUnit: Boolean = true) { fun destroy(destroyTransportedUnit: Boolean = true) {
stopEscorting() stopEscorting()
val currentPosition = Vector2(getTile().position)
civ.attacksSinceTurnStart.addAll(attacksSinceTurnStart.asSequence().map { Civilization.HistoricalAttackMemory(this.name, currentPosition, it) })
currentMovement = 0f currentMovement = 0f
removeFromTile()
civ.units.removeUnit(this) civ.units.removeUnit(this)
civ.cache.updateViewableTiles() if (::currentTile.isInitialized) {
if (destroyTransportedUnit) { val currentPosition = Vector2(getTile().position)
// all transported units should be destroyed as well civ.attacksSinceTurnStart.addAll(attacksSinceTurnStart.asSequence().map { Civilization.HistoricalAttackMemory(this.name, currentPosition, it) })
currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) } removeFromTile()
.toList() // because we're changing the list civ.cache.updateViewableTiles()
.forEach { unit -> unit.destroy() } 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 isDestroyed = true
} }
@ -784,7 +791,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
civ.cache.updateViewableTiles() civ.cache.updateViewableTiles()
// all transported units should be gift as well // all transported units should be gift as well
currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) } currentTile.getUnits().filter { it.isTransported && isTransportTypeOf(it) }
.forEach { unit -> unit.gift(recipient) } .forEach { unit -> unit.gift(recipient) }
assignOwner(recipient) assignOwner(recipient)
recipient.cache.updateViewableTiles() recipient.cache.updateViewableTiles()
} }
@ -817,7 +824,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
civ.addStat(stat.key, stat.value.toInt()) civ.addStat(stat.key, stat.value.toInt())
civ.addNotification("By expending your [$name] you gained [${gainedStats.toStringForNotifications()}]!", 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) fun removeFromTile() = currentTile.removeUnit(this)
@ -855,8 +862,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
} }
val promotionUniques = tile.neighbors val promotionUniques = tile.neighbors
.flatMap { it.allTerrains } .flatMap { it.allTerrains }
.flatMap { it.getMatchingUniques(UniqueType.TerrainGrantsPromotion) } .flatMap { it.getMatchingUniques(UniqueType.TerrainGrantsPromotion) }
for (unique in promotionUniques) { for (unique in promotionUniques) {
if (!this.matchesFilter(unique.params[2])) continue if (!this.matchesFilter(unique.params[2])) continue
val promotion = unique.params[0] val promotion = unique.params[0]
@ -871,6 +878,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
when { when {
!movement.canMoveTo(tile) -> !movement.canMoveTo(tile) ->
throw Exception("Unit $name of ${civ.civName} at $currentTile can't be put in tile $tile!") throw Exception("Unit $name of ${civ.civName} at $currentTile can't be put in tile $tile!")
baseUnit.movesLikeAirUnits() -> tile.airUnits.add(this) baseUnit.movesLikeAirUnits() -> tile.airUnits.add(this)
isCivilian() -> tile.civilianUnit = this isCivilian() -> tile.civilianUnit = this
else -> tile.militaryUnit = this else -> tile.militaryUnit = this
@ -894,7 +902,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun stopEscorting() { fun stopEscorting() {
getOtherEscortUnit()?.escorting = false getOtherEscortUnit()?.escorting = false
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) { 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 // Notify City-States that this unit cleared a Barbarian Encampment, required for quests
civ.gameInfo.getAliveCityStates() civ.gameInfo.getAliveCityStates()
.forEach { it.questManager.barbarianCampCleared(civ, tile.position) } .forEach { it.questManager.barbarianCampCleared(civ, tile.position) }
var goldGained = var goldGained =
civ.getDifficulty().clearBarbarianCampReward * civ.gameInfo.speed.goldCostModifier civ.getDifficulty().clearBarbarianCampReward * civ.gameInfo.speed.goldCostModifier
if (civ.hasUnique(UniqueType.TripleGoldFromEncampmentsAndCities)) if (civ.hasUnique(UniqueType.TripleGoldFromEncampmentsAndCities))
goldGained *= 3f goldGained *= 3f
civ.addGold(goldGained.toInt()) civ.addGold(goldGained.toInt())
civ.addNotification( civ.addNotification(
"We have captured a barbarian encampment and recovered [${goldGained.toInt()}] gold!", "We have captured a barbarian encampment and recovered [${goldGained.toInt()}] gold!",
tile.position, tile.position,
NotificationCategory.War, NotificationCategory.War,
NotificationIcon.Gold NotificationIcon.Gold
) )
} }
fun disband() { fun disband() {
// evacuation of transported units before disbanding, if possible. toListed because we're modifying the unit list. // evacuation of transported units before disbanding, if possible. toListed because we're modifying the unit list.
for (unit in currentTile.getUnits() for (unit in currentTile.getUnits()
.filter { it.isTransported && isTransportTypeOf(it) } .filter { it.isTransported && isTransportTypeOf(it) }
.toList() .toList()
) { ) {
// if we disbanded a unit carrying other units in a city, the carried units can still stay in the city // 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)) { if (currentTile.isCityCenter() && unit.movement.canMoveTo(currentTile)) {
@ -934,7 +943,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
unit.disband() unit.disband()
// let's find closest city or another carrier where it can be evacuated // let's find closest city or another carrier where it can be evacuated
val tileCanMoveTo = unit.currentTile.getTilesInDistance(unit.getMaxMovementForAirUnits()) 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) if (tileCanMoveTo != null)
unit.movement.moveToTile(tileCanMoveTo) unit.movement.moveToTile(tileCanMoveTo)