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 (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
}
}

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
@ -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<Unique> = 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<Unique> {
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)