chore(purity): MapUnit

This commit is contained in:
yairm210 2025-07-23 23:39:27 +03:00
parent 5fe0517c81
commit be6216d2fb
6 changed files with 72 additions and 36 deletions

View File

@ -56,6 +56,7 @@ allprojects {
"com.badlogic.gdx.math.Vector2.len",
"com.badlogic.gdx.math.Vector2.cpy",
"kotlin.collections.Collection.contains",
"kotlin.collections.dropLastWhile",
)
wellKnownPureClasses = setOf(
"java.text.NumberFormat"

View File

@ -26,6 +26,7 @@ import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.models.ruleset.unit.UnitType
import com.unciv.models.translations.tr
import com.unciv.ui.components.UnitMovementMemoryType
import yairm210.purity.annotations.LocalState
import yairm210.purity.annotations.Readonly
import java.text.DecimalFormat
import kotlin.math.pow
@ -252,28 +253,30 @@ class MapUnit : IsPartOfGameInfoSerialization {
return turnsFortified
}
fun isSleeping() = action?.startsWith(UnitActionType.Sleep.value) == true
fun isSleepingUntilHealed() = isSleeping() && isActionUntilHealed()
@Readonly fun isSleeping() = action?.startsWith(UnitActionType.Sleep.value) == true
@Readonly fun isSleepingUntilHealed() = isSleeping() && isActionUntilHealed()
fun isMoving() = action?.startsWith("moveTo") == true
@Readonly fun isMoving() = action?.startsWith("moveTo") == true
@Readonly
fun getMovementDestination(): Tile {
val destination = action!!.replace("moveTo ", "").split(",").dropLastWhile { it.isEmpty() }
val destinationVector = Vector2(destination[0].toFloat(), destination[1].toFloat())
return currentTile.tileMap[destinationVector]
}
fun isAutomated() = automated
@Readonly fun isAutomated() = automated
fun isAutomatingRoadConnection() = action == UnitActionType.ConnectRoad.value
fun isExploring() = action == UnitActionType.Explore.value
fun isPreparingParadrop() = action == UnitActionType.Paradrop.value
fun isPreparingAirSweep() = action == UnitActionType.AirSweep.value
fun isSetUpForSiege() = action == UnitActionType.SetUp.value
@Readonly fun isAutomatingRoadConnection() = action == UnitActionType.ConnectRoad.value
@Readonly fun isExploring() = action == UnitActionType.Explore.value
@Readonly fun isPreparingParadrop() = action == UnitActionType.Paradrop.value
@Readonly fun isPreparingAirSweep() = action == UnitActionType.AirSweep.value
@Readonly fun isSetUpForSiege() = action == UnitActionType.SetUp.value
/**
* @param includeOtherEscortUnit determines whether this method will also check if it's other escort unit is idle if it has one
* Leave it as default unless you know what [isIdle] does.
*/
@Readonly
fun isIdle(includeOtherEscortUnit: Boolean = true): Boolean {
if (!hasMovement()) return false
val tile = getTile()
@ -285,7 +288,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return !(isFortified() || isExploring() || isSleeping() || isAutomated() || isMoving() || isGuarding())
}
fun getUniques(): Sequence<Unique> = tempUniquesMap.getAllUniques()
@Readonly fun getUniques(): Sequence<Unique> = tempUniquesMap.getAllUniques()
@Readonly
fun getMatchingUniques(
@ -309,6 +312,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return getMatchingUniques(uniqueType, gameContext, checkCivInfoUniques).any()
}
@Readonly
fun getTriggeredUniques(
trigger: UniqueType,
gameContext: GameContext = cache.state,
@ -320,14 +324,16 @@ class MapUnit : IsPartOfGameInfoSerialization {
/** Gets *per turn* resource requirements - does not include immediate costs for stockpiled resources.
* StateForConditionals is assumed to regarding this mapUnit*/
@Readonly
fun getResourceRequirementsPerTurn(): Counter<String> {
val resourceRequirements = Counter<String>()
@LocalState val resourceRequirements = Counter<String>()
if (baseUnit.requiredResource != null) resourceRequirements[baseUnit.requiredResource!!] = 1
for (unique in getMatchingUniques(UniqueType.ConsumesResources, cache.state))
resourceRequirements[unique.params[1]] += unique.params[0].toInt()
resourceRequirements.add(unique.params[1], unique.params[0].toInt())
return resourceRequirements
}
@Readonly
fun requiresResource(resource: String): Boolean {
if (getResourceRequirementsPerTurn().contains(resource)) return true
for (unique in getMatchingUniques(UniqueType.CostsResources, cache.state)) {
@ -336,8 +342,9 @@ class MapUnit : IsPartOfGameInfoSerialization {
return false
}
fun hasMovement() = currentMovement > 0
@Readonly fun hasMovement() = currentMovement > 0
@Readonly
fun getMaxMovement(ignoreOtherUnit: Boolean = false): Int {
var movement =
if (isEmbarked()) 2
@ -364,6 +371,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return movement
}
@Readonly
fun hasUnitMovedThisTurn(): Boolean {
val max = getMaxMovement().toFloat()
return currentMovement < max - max.ulp
@ -373,6 +381,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
* Determines this (land or sea) unit's current maximum vision range from unit properties, civ uniques and terrain.
* @return Maximum distance of tiles this unit may possibly see
*/
@Readonly
private fun getVisibilityRange(): Int {
var visibilityRange = 2
@ -387,17 +396,20 @@ class MapUnit : IsPartOfGameInfoSerialization {
return visibilityRange
}
@Readonly
fun maxAttacksPerTurn(): Int {
return 1 + getMatchingUniques(UniqueType.AdditionalAttacks, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() }
}
@Readonly
fun canAttack(): Boolean {
if (!hasMovement()) return false
if (isCivilian()) return false
return attacksThisTurn < maxAttacksPerTurn()
}
@Readonly
fun getRange(): Int {
if (baseUnit.isMelee()) return 1
var range = baseUnit.range
@ -406,9 +418,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return range
}
fun getMaxMovementForAirUnits(): Int {
return getRange() * 2
}
@Readonly fun getMaxMovementForAirUnits(): Int = getRange() * 2
@Readonly
fun isEmbarked(): Boolean {
@ -428,7 +438,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return false
}
@Readonly
fun canFortify(ignoreAlreadyFortified: Boolean = false) = when {
baseUnit.isWaterUnit -> false
isCivilian() -> false
@ -440,10 +450,12 @@ class MapUnit : IsPartOfGameInfoSerialization {
else -> true
}
@Readonly
private fun adjacentHealingBonus(): Int {
return getMatchingUniques(UniqueType.HealAdjacentUnits).sumOf { it.params[0].toInt() }
}
@Readonly
fun getHealAmountForCurrentTile() = when {
isEmbarked() -> 0 // embarked units can't heal
health >= 100 -> 0 // No need to heal if at max health
@ -451,9 +463,10 @@ class MapUnit : IsPartOfGameInfoSerialization {
else -> rankTileForHealing(getTile())
}
fun canHealInCurrentTile() = getHealAmountForCurrentTile() > 0
@Readonly fun canHealInCurrentTile() = getHealAmountForCurrentTile() > 0
/** Returns the health points [MapUnit] will receive if healing on [tile] */
@Readonly
fun rankTileForHealing(tile: Tile): Int {
val isFriendlyTerritory = tile.isFriendlyTerritory(civ)
@ -499,20 +512,23 @@ class MapUnit : IsPartOfGameInfoSerialization {
@Readonly fun canGarrison() = isMilitary() && baseUnit.isLandUnit
@Readonly fun isGreatPerson() = baseUnit.isGreatPerson
fun isGreatPersonOfType(type: String) = baseUnit.isGreatPersonOfType(type)
@Readonly fun isGreatPersonOfType(type: String) = baseUnit.isGreatPersonOfType(type)
@Readonly
fun canIntercept(attackedTile: Tile): Boolean {
if (!canIntercept()) return false
if (currentTile.aerialDistanceTo(attackedTile) > getInterceptionRange()) return false
return true
}
@Readonly
fun getInterceptionRange(): Int {
val rangeFromUniques = getMatchingUniques(UniqueType.AirInterceptionRange, checkCivInfoUniques = true)
.sumOf { it.params[0].toInt() }
return baseUnit.interceptRange + rangeFromUniques
}
@Readonly
fun canIntercept(): Boolean {
if (interceptChance() == 0) return false
// Air Units can only Intercept if they didn't move this turn
@ -524,15 +540,18 @@ class MapUnit : IsPartOfGameInfoSerialization {
return true
}
@Readonly
fun interceptChance(): Int {
return getMatchingUniques(UniqueType.ChanceInterceptAirAttacks).sumOf { it.params[0].toInt() }
}
@Readonly
fun interceptDamagePercentBonus(): Int {
return getMatchingUniques(UniqueType.DamageWhenIntercepting)
.sumOf { it.params[0].toInt() }
}
@Readonly
fun receivedInterceptDamageFactor(): Float {
var damageFactor = 1f
for (unique in getMatchingUniques(UniqueType.DamageFromInterceptionReduced))
@ -540,16 +559,19 @@ class MapUnit : IsPartOfGameInfoSerialization {
return damageFactor
}
@Readonly
fun getDamageFromTerrain(tile: Tile = currentTile): Int {
return tile.allTerrains.sumOf { it.damagePerTurn }
}
@Readonly
fun isTransportTypeOf(mapUnit: MapUnit): Boolean {
// Currently, only missiles and airplanes can be carried
if (!mapUnit.baseUnit.movesLikeAirUnits) return false
return getMatchingUniques(UniqueType.CarryAirUnits).any { mapUnit.matchesFilter(it.params[1]) }
}
@Readonly
private fun carryCapacity(unit: MapUnit): Int {
return (getMatchingUniques(UniqueType.CarryAirUnits)
+ getMatchingUniques(UniqueType.CarryExtraAirUnits))
@ -557,6 +579,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
.sumOf { it.params[0].toInt() }
}
@Readonly
fun canTransport(unit: MapUnit): Boolean {
if (owner != unit.owner) return false
if (!isTransportTypeOf(unit)) return false
@ -566,10 +589,12 @@ 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. */
@Readonly
fun getNukeBlastRadius() = getMatchingUniques(UniqueType.BlastRadius)
// Don't check conditionals as these are not supported
.firstOrNull()?.params?.get(0)?.toInt() ?: 2
@Readonly
private fun isAlly(otherCiv: Civilization): Boolean {
return otherCiv == civ
|| (otherCiv.isCityState && otherCiv.getAllyCivName() == civ.civName)
@ -577,10 +602,12 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
/** Implements [UniqueParameterType.MapUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.MapUnitFilter] */
@Readonly
fun matchesFilter(filter: String, multiFilter: Boolean = true): Boolean {
return if (multiFilter) MultiFilter.multiFilter(filter, ::matchesSingleFilter) else matchesSingleFilter(filter)
}
@Readonly
private fun matchesSingleFilter(filter: String): Boolean {
return when (filter) {
Constants.wounded, "wounded units" -> health < 100
@ -599,6 +626,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
}
@Readonly
fun canBuildImprovement(improvement: TileImprovement, tile: Tile = currentTile): Boolean {
if (civ.isBarbarian) return false
@ -617,6 +645,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
.any { improvement.matchesFilter(it.params[0], cache.state) || tile.matchesTerrainFilter(it.params[0], civ) }
}
@Readonly
fun getReligionDisplayName(): String? {
if (religion == null) return null
return civ.gameInfo.religions[religion]!!.getReligionDisplayName()
@ -631,6 +660,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return power
}
@Readonly
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
@ -638,6 +668,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return null
}
@Readonly @Suppress("purity") // Updates escorting state
fun isEscorting(): Boolean {
if (escorting) {
if (getOtherEscortUnit() != null) return true
@ -646,6 +677,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
return false
}
@Readonly
fun threatensCiv(civInfo: Civilization): Boolean {
if (getTile().getOwner() == civInfo)
return true
@ -1051,8 +1083,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
}
fun getStatus(name:String): UnitStatus? = statusMap[name]
fun hasStatus(name:String): Boolean = getStatus(name) != null
@Readonly fun getStatus(name:String): UnitStatus? = statusMap[name]
@Readonly fun hasStatus(name:String): Boolean = getStatus(name) != null
fun setStatus(name:String, turns:Int){
val existingStatus = getStatus(name)

View File

@ -261,7 +261,7 @@ class Tile : IsPartOfGameInfoSerialization, Json.Serializable {
return null
}
fun getCity(): City? = owningCity
@Readonly fun getCity(): City? = owningCity
@Readonly internal fun getNaturalWonder(): Terrain =
if (naturalWonder == null) throw Exception("No natural wonder exists for this tile!")
@ -281,7 +281,7 @@ class Tile : IsPartOfGameInfoSerialization, Json.Serializable {
return exploredBy.contains(player.civName)
}
fun isCityCenter(): Boolean = isCityCenterInternal
@Readonly fun isCityCenter(): Boolean = isCityCenterInternal
@Readonly fun isNaturalWonder(): Boolean = naturalWonder != null
@Readonly fun isImpassible() = lastTerrain.impassable

View File

@ -70,10 +70,12 @@ class Religion() : INamed, IsPartOfGameInfoSerialization {
updateUniqueMaps()
}
@Readonly
fun getIconName() =
if (isPantheon()) "Pantheon"
else name
@Readonly
fun getReligionDisplayName() =
if (displayName != null) displayName!!
else name

View File

@ -22,11 +22,9 @@ class Technology: RulesetObject() {
var row: Int = 0
var quote = ""
@Readonly
fun era(): String = column!!.era
@Readonly fun era(): String = column!!.era
@Readonly
fun isContinuallyResearchable() = hasUnique(UniqueType.ResearchableMultipleTimes)
@Readonly fun isContinuallyResearchable() = hasUnique(UniqueType.ResearchableMultipleTimes)
/** Get Civilization-specific description for TechPicker or AlertType.TechResearched */
@ -40,6 +38,7 @@ class Technology: RulesetObject() {
override fun era(ruleset: Ruleset) = ruleset.eras[era()]
@Readonly
fun matchesFilter(filter: String, state: GameContext? = null, multiFilter: Boolean = true): Boolean {
return if (multiFilter) MultiFilter.multiFilter(filter, {
matchesSingleFilter(filter) ||
@ -51,6 +50,7 @@ class Technology: RulesetObject() {
state == null && hasTagUnique(filter)
}
@Readonly
fun matchesSingleFilter(filter: String): Boolean {
return when (filter) {
in Constants.all -> true

View File

@ -411,9 +411,10 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
}
private val cachedMatchesFilterResult = HashMap<String, Boolean>()
@LocalState private val cachedMatchesFilterResult = HashMap<String, Boolean>()
/** Implements [UniqueParameterType.BaseUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.BaseUnitFilter] */
@Readonly
fun matchesFilter(filter: String, state: GameContext? = null, multiFilter: Boolean = true): Boolean {
return if (multiFilter) MultiFilter.multiFilter(filter, {
cachedMatchesFilterResult.getOrPut(it) { matchesSingleFilter(it) } ||
@ -425,7 +426,7 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
state == null && hasTagUnique(filter)
}
@Readonly
fun matchesSingleFilter(filter: String): Boolean {
// all cases are constants for performance
return when (filter) {
@ -464,10 +465,10 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
/** Determine whether this is a City-founding unit - abstract, **without any game context**.
* Use other methods for MapUnits or when there is a better StateForConditionals available. */
fun isCityFounder() = hasUnique(UniqueType.FoundCity, GameContext.IgnoreConditionals)
@Readonly fun isCityFounder() = hasUnique(UniqueType.FoundCity, GameContext.IgnoreConditionals)
val isGreatPerson by lazy { getMatchingUniques(UniqueType.GreatPerson).any() }
fun isGreatPersonOfType(type: String) = getMatchingUniques(UniqueType.GreatPerson).any { it.params[0] == type }
@Readonly fun isGreatPersonOfType(type: String) = getMatchingUniques(UniqueType.GreatPerson).any { it.params[0] == type }
/** Has a MapUnit implementation that does not ignore conditionals, which should be usually used */
@Readonly private fun isNuclearWeapon() = hasUnique(UniqueType.NuclearWeapon, GameContext.IgnoreConditionals)