chore: purity - city round 1

This commit is contained in:
yairm210 2025-07-18 13:59:22 +03:00
parent 15d915d63f
commit 0eacc385d3
14 changed files with 79 additions and 41 deletions

View File

@ -59,6 +59,8 @@ allprojects {
"com.badlogic.gdx.math.Vector2.len",
"com.badlogic.gdx.math.Vector2.cpy",
"java.util.AbstractCollection.contains",
"java.util.AbstractCollection.isEmpty",
"java.util.AbstractCollection.iterator",
"java.util.AbstractList.get",
)
wellKnownPureClasses = setOf(

View File

@ -28,6 +28,7 @@ import com.unciv.models.stats.GameResource
import com.unciv.models.stats.INamed
import com.unciv.models.stats.Stat
import com.unciv.models.stats.SubStat
import yairm210.purity.annotations.LocalState
import yairm210.purity.annotations.Readonly
import java.util.UUID
import kotlin.math.roundToInt
@ -105,7 +106,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
private var cityAIFocus: String = CityFocus.NoFocus.name
fun getCityFocus() = CityFocus.entries.firstOrNull { it.name == cityAIFocus } ?: CityFocus.NoFocus
@Readonly fun getCityFocus() = CityFocus.entries.firstOrNull { it.name == cityAIFocus } ?: CityFocus.NoFocus
fun setCityFocus(cityFocus: CityFocus){ cityAIFocus = cityFocus.name }
@ -130,7 +131,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
enum class ConnectedToCapitalStatus { Unknown, `false`, `true` }
var connectedToCapitalStatus = ConnectedToCapitalStatus.Unknown
fun hasDiplomaticMarriage(): Boolean = foundingCiv == ""
@Readonly fun hasDiplomaticMarriage(): Boolean = foundingCiv == ""
//region pure functions
fun clone(): City {
@ -181,12 +182,14 @@ class City : IsPartOfGameInfoSerialization, INamed {
@Readonly fun getWorkRange(): Int = civ.gameInfo.ruleset.modOptions.constants.cityWorkRange
@Readonly fun getExpandRange(): Int = civ.gameInfo.ruleset.modOptions.constants.cityExpandRange
@Readonly @Suppress("purity") // Activates predicate
fun isConnectedToCapital(connectionTypePredicate: (Set<String>) -> Boolean = { true }): Boolean {
val mediumTypes = civ.cache.citiesConnectedToCapitalToMediums[this] ?: return false
return connectionTypePredicate(mediumTypes)
}
fun isGarrisoned() = getGarrison() != null
@Readonly fun isGarrisoned() = getGarrison() != null
@Readonly
fun getGarrison(): MapUnit? =
getCenterTile().militaryUnit?.takeIf {
it.civ == this.civ && it.canGarrison()
@ -195,7 +198,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
@Readonly fun hasFlag(flag: CityFlags) = flagsCountdown.containsKey(flag.name)
@Readonly fun getFlag(flag: CityFlags) = flagsCountdown[flag.name]!!
fun isWeLoveTheKingDayActive() = hasFlag(CityFlags.WeLoveTheKing)
@Readonly fun isWeLoveTheKingDayActive() = hasFlag(CityFlags.WeLoveTheKing)
@Readonly fun isInResistance() = hasFlag(CityFlags.Resistance)
fun isBlockaded(): Boolean {
// Coastal cities are blocked if every adjacent water tile is blocked
@ -205,22 +208,22 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
}
fun getRuleset() = civ.gameInfo.ruleset
@Readonly fun getRuleset() = civ.gameInfo.ruleset
fun getResourcesGeneratedByCity(civResourceModifiers: HashMap<String, Float>) = CityResources.getResourcesGeneratedByCity(this, civResourceModifiers)
fun getAvailableResourceAmount(resourceName: String) = CityResources.getAvailableResourceAmount(this, resourceName)
fun isGrowing() = foodForNextTurn() > 0
fun isStarving() = foodForNextTurn() < 0
fun foodForNextTurn() = cityStats.currentCityStats.food.roundToInt()
@Readonly fun isGrowing() = foodForNextTurn() > 0
@Readonly fun isStarving() = foodForNextTurn() < 0
@Readonly fun foodForNextTurn() = cityStats.currentCityStats.food.roundToInt()
@Readonly
fun containsBuildingUnique(uniqueType: UniqueType, state: GameContext = this.state) =
cityConstructions.builtBuildingUniqueMap.getMatchingUniques(uniqueType, state).any()
fun getGreatPersonPercentageBonus() = GreatPersonPointsBreakdown.getGreatPersonPercentageBonus(this)
fun getGreatPersonPoints() = GreatPersonPointsBreakdown(this).sum()
@Readonly fun getGreatPersonPercentageBonus() = GreatPersonPointsBreakdown.getGreatPersonPercentageBonus(this)
@Readonly fun getGreatPersonPoints() = GreatPersonPointsBreakdown(this).sum()
fun gainStockpiledResource(resource: TileResource, amount: Int) {
if (resource.isCityWide) resourceStockpiles.add(resource.name, amount)
@ -247,7 +250,8 @@ class City : IsPartOfGameInfoSerialization, INamed {
else -> civ.addGameResource(stat, amount)
}
}
@Readonly
fun getStatReserve(stat: Stat): Int {
return when (stat) {
Stat.Production -> cityConstructions.getWorkDone(cityConstructions.getCurrentConstruction().name)
@ -269,6 +273,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
}
@Readonly
fun hasStatToBuy(stat: Stat, price: Int): Boolean {
return when {
civ.gameInfo.gameParameters.godMode -> true
@ -277,8 +282,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
}
internal fun getMaxHealth() =
200 + cityConstructions.getBuiltBuildings().sumOf { it.cityHealth }
@Readonly internal fun getMaxHealth() = 200 + cityConstructions.getBuiltBuildings().sumOf { it.cityHealth }
@Readonly fun getStrength() = cityConstructions.getBuiltBuildings().sumOf { it.cityStrength }.toFloat()
@ -290,9 +294,10 @@ class City : IsPartOfGameInfoSerialization, INamed {
override fun toString() = name // for debug
fun isHolyCity(): Boolean = religion.religionThisIsTheHolyCityOf != null && !religion.isBlockedHolyCity
fun isHolyCityOf(religionName: String?) = isHolyCity() && religion.religionThisIsTheHolyCityOf == religionName
@Readonly fun isHolyCity(): Boolean = religion.religionThisIsTheHolyCityOf != null && !religion.isBlockedHolyCity
@Readonly fun isHolyCityOf(religionName: String?) = isHolyCity() && religion.religionThisIsTheHolyCityOf == religionName
@Readonly
fun canBeDestroyed(justCaptured: Boolean = false): Boolean {
if (civ.gameInfo.gameParameters.noCityRazing) return false
@ -440,6 +445,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
getCenterTile().setRoadStatus(requiredRoad, civ)
}
@Readonly
fun getGoldForSellingBuilding(buildingName: String) =
getRuleset().buildings[buildingName]!!.cost / 10
@ -467,12 +473,14 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
/** Implements [UniqueParameterType.CityFilter][com.unciv.models.ruleset.unique.UniqueParameterType.CityFilter] */
@Readonly
fun matchesFilter(filter: String, viewingCiv: Civilization? = civ, multiFilter: Boolean = true): Boolean {
return if (multiFilter)
MultiFilter.multiFilter(filter, { matchesSingleFilter(it, viewingCiv) })
else matchesSingleFilter(filter, viewingCiv)
}
@Readonly
private fun matchesSingleFilter(filter: String, viewingCiv: Civilization? = civ): Boolean {
return when (filter) {
"in this city" -> true // Filtered by the way uniques are found
@ -521,6 +529,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
// Sadly, due to the large disparity between use cases, there needed to be lots of functions.
// Finds matching uniques provided from both local and non-local sources.
@Readonly
fun getMatchingUniques(
uniqueType: UniqueType,
gameContext: GameContext = state,
@ -538,6 +547,7 @@ class City : IsPartOfGameInfoSerialization, INamed {
}
// Uniques special to this city
@Readonly
fun getLocalMatchingUniques(uniqueType: UniqueType, gameContext: GameContext = state): Sequence<Unique> {
val uniques = cityConstructions.builtBuildingUniqueMap.getUniques(uniqueType).filter { it.isLocalEffect } +
religion.getUniques(uniqueType)

View File

@ -200,7 +200,7 @@ class CityConstructions : IsPartOfGameInfoSerialization {
else FormattedLine(label, link="$category/$currentConstructionSnapshot")
}
fun getCurrentConstruction(): IConstruction = getConstruction(currentConstructionFromQueue)
@Readonly fun getCurrentConstruction(): IConstruction = getConstruction(currentConstructionFromQueue)
fun isBuilt(buildingName: String): Boolean = builtBuildings.contains(buildingName)
@ -238,6 +238,7 @@ class CityConstructions : IsPartOfGameInfoSerialization {
}
@Readonly
internal fun getConstruction(constructionName: String): IConstruction {
val gameBasics = city.getRuleset()
when {
@ -259,6 +260,7 @@ class CityConstructions : IsPartOfGameInfoSerialization {
fun containsBuildingOrEquivalent(buildingNameOrUnique: String): Boolean =
isBuilt(buildingNameOrUnique) || getBuiltBuildings().any { it.replaces == buildingNameOrUnique || it.hasUnique(buildingNameOrUnique, city.state) }
@Readonly
fun getWorkDone(constructionName: String): Int {
return if (inProgressConstructions.containsKey(constructionName)) inProgressConstructions[constructionName]!!
else 0

View File

@ -5,6 +5,7 @@ import com.unciv.models.ruleset.tile.ResourceSupplyList
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.ruleset.unique.GameContext
import com.unciv.models.ruleset.unique.UniqueType
import yairm210.purity.annotations.Readonly
object CityResources {

View File

@ -15,6 +15,7 @@ import com.unciv.models.stats.StatMap
import com.unciv.models.stats.Stats
import com.unciv.ui.components.extensions.toPercent
import com.unciv.utils.DebugUtils
import yairm210.purity.annotations.Readonly
import kotlin.math.min
@ -172,6 +173,7 @@ class CityStats(val city: City) {
return growthSources
}
@Readonly
fun hasExtraAnnexUnhappiness(): Boolean {
if (city.civ.civName == city.foundingCiv || city.isPuppet) return false
return !city.containsBuildingUnique(UniqueType.RemoveAnnexUnhappiness)

View File

@ -5,6 +5,8 @@ import com.unciv.models.Counter
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import yairm210.purity.annotations.LocalState
import yairm210.purity.annotations.Readonly
/** Manages calculating Great Person Points per City for nextTurn. See public constructor(city) below for details. */
class GreatPersonPointsBreakdown private constructor(private val ruleset: Ruleset) {
@ -38,8 +40,9 @@ class GreatPersonPointsBreakdown private constructor(private val ruleset: Rulese
const val fixedPointFactor = 1000
private fun getUniqueSourceName(unique: Unique) = unique.sourceObjectName ?: "Bonus"
@Readonly private fun getUniqueSourceName(unique: Unique) = unique.sourceObjectName ?: "Bonus"
@Readonly
private fun guessPediaLink(unique: Unique): String? {
if (unique.sourceObjectName == null) return null
return unique.sourceObjectType!!.name + "/" + unique.sourceObjectName
@ -50,6 +53,7 @@ class GreatPersonPointsBreakdown private constructor(private val ruleset: Rulese
* This is used internally from the public constructor to include them in the brakdown,
* and exposed to autoAssignPopulation via [getGreatPersonPercentageBonus]
*/
@Readonly
private fun getPercentagesApplyingToAllGP(city: City) = sequence {
// Now add boni for GreatPersonPointPercentage
for (unique in city.getMatchingUniques(UniqueType.GreatPersonPointPercentage)) {
@ -73,6 +77,7 @@ class GreatPersonPointsBreakdown private constructor(private val ruleset: Rulese
*
* For use by [City.getGreatPersonPercentageBonus] (which in turn is only used by autoAssignPopulation)
*/
@Readonly
fun getGreatPersonPercentageBonus(city: City) = getPercentagesApplyingToAllGP(city).sumOf { it.bonus }
}
@ -126,13 +131,16 @@ class GreatPersonPointsBreakdown private constructor(private val ruleset: Rulese
}
/** Aggregate over sources, applying percentage boni using fixed-point math to avoid rounding surprises */
@Readonly
fun sum(): Counter<String> {
// Accumulate base points as fake "fixed-point"
@LocalState
val result = Counter<String>()
for (entry in basePoints)
result.add(entry.counter * fixedPointFactor)
// Accumulate percentage bonuses additively not multiplicatively
@LocalState
val bonuses = Counter<String>()
for (entry in percentBonuses) {
bonuses.add(entry.counter)

View File

@ -10,6 +10,7 @@ import com.unciv.models.Religion
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.extensions.toPercent
import yairm210.purity.annotations.Readonly
class CityReligionManager : IsPartOfGameInfoSerialization {
@Transient
@ -57,6 +58,7 @@ class CityReligionManager : IsPartOfGameInfoSerialization {
getAffectedBySurroundingCities()
}
@Readonly
fun getUniques(uniqueType: UniqueType): Sequence<Unique> {
val majorityReligion = getMajorityReligion() ?: return emptySequence()
return majorityReligion.followerBeliefUniqueMap.getUniques(uniqueType)
@ -223,6 +225,7 @@ class CityReligionManager : IsPartOfGameInfoSerialization {
updateNumberOfFollowers()
}
@Readonly
fun getMajorityReligionName(): String? {
if (followers.isEmpty()) return null
val religionWithMaxPressure = followers.maxByOrNull { it.value }!!.key
@ -233,6 +236,7 @@ class CityReligionManager : IsPartOfGameInfoSerialization {
}
}
@Readonly
fun getMajorityReligion(): Religion? {
val majorityReligionName = getMajorityReligionName() ?: return null
return city.civ.gameInfo.religions[majorityReligionName]

View File

@ -909,6 +909,7 @@ class Civilization : IsPartOfGameInfoSerialization {
resourceStockpiles.add(resource.name, amount)
}
@Readonly
fun getStatReserve(stat: Stat): Int {
return when (stat) {
Stat.Culture -> policies.storedCulture

View File

@ -538,12 +538,12 @@ class DiplomacyManager() : IsPartOfGameInfoSerialization {
}
}
fun hasFlag(flag: DiplomacyFlags) = flagsCountdown.containsKey(flag.name)
@Readonly fun hasFlag(flag: DiplomacyFlags) = flagsCountdown.containsKey(flag.name)
fun setFlag(flag: DiplomacyFlags, amount: Int) {
flagsCountdown[flag.name] = amount
}
fun getFlag(flag: DiplomacyFlags) = flagsCountdown[flag.name]!!
@Readonly fun getFlag(flag: DiplomacyFlags) = flagsCountdown[flag.name]!!
fun removeFlag(flag: DiplomacyFlags) {
flagsCountdown.remove(flag.name)
}

View File

@ -108,8 +108,7 @@ class TechManager : IsPartOfGameInfoSerialization {
return 1 + numberOfCivsResearchedThisTech / numberOfCivsRemaining.toFloat() * 0.3f
}
@Readonly
private fun getRuleset() = civInfo.gameInfo.ruleset
@Readonly private fun getRuleset() = civInfo.gameInfo.ruleset
fun costOfTech(techName: String): Int {
var techCost = getRuleset().technologies[techName]!!.cost.toFloat()
@ -126,16 +125,18 @@ class TechManager : IsPartOfGameInfoSerialization {
return techCost.toInt()
}
@Readonly
fun currentTechnology(): Technology? {
val currentTechnologyName = currentTechnologyName() ?: return null
return getRuleset().technologies[currentTechnologyName]
}
@Readonly
fun currentTechnologyName(): String? {
return if (techsToResearch.isEmpty()) null else techsToResearch[0]
}
fun researchOfTech(techName: String?) = techsInProgress[techName] ?: 0
@Readonly fun researchOfTech(techName: String?) = techsInProgress[techName] ?: 0
// Was once duplicated as fun scienceSpentOnTech(tech: String): Int
fun remainingScienceToTech(techName: String): Int {

View File

@ -238,14 +238,15 @@ class MapUnit : IsPartOfGameInfoSerialization {
it.getCenterTile().aerialDistanceTo(currentTile)
}
fun isMilitary() = baseUnit.isMilitary
fun isCivilian() = baseUnit.isCivilian()
@Readonly fun isMilitary() = baseUnit.isMilitary
@Readonly fun isCivilian() = baseUnit.isCivilian()
fun isActionUntilHealed() = action?.endsWith("until healed") == true
@Readonly fun isActionUntilHealed() = action?.endsWith("until healed") == true
fun isFortified() = action?.startsWith(UnitActionType.Fortify.value) == true
fun isGuarding() = action?.equals(UnitActionType.Guard.value) == true
fun isFortifyingUntilHealed() = isFortified() && isActionUntilHealed()
@Readonly fun isFortified() = action?.startsWith(UnitActionType.Fortify.value) == true
@Readonly fun isGuarding() = action?.equals(UnitActionType.Guard.value) == true
@Readonly fun isFortifyingUntilHealed() = isFortified() && isActionUntilHealed()
@Readonly
fun getFortificationTurns(): Int {
if (!(isFortified() || isGuarding())) return 0
return turnsFortified
@ -495,7 +496,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
// Only military land units can truly "garrison"
fun canGarrison() = isMilitary() && baseUnit.isLandUnit
@Readonly fun canGarrison() = isMilitary() && baseUnit.isLandUnit
@Readonly fun isGreatPerson() = baseUnit.isGreatPerson
fun isGreatPersonOfType(type: String) = baseUnit.isGreatPersonOfType(type)

View File

@ -3,6 +3,7 @@ package com.unciv.models
import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.JsonValue
import com.unciv.logic.IsPartOfGameInfoSerialization
import yairm210.purity.annotations.Readonly
/**
* Implements a specialized Map storing on-zero Integers.
@ -47,6 +48,8 @@ open class Counter<K>(
}
operator fun minusAssign(other: Counter<K>) = remove(other)
@Readonly
/** Creates a new instance (does not modify) */
operator fun times(amount: Int): Counter<K> {
val newCounter = Counter<K>()
for (key in keys) newCounter[key] = this[key] * amount

View File

@ -10,6 +10,7 @@ import com.unciv.models.ruleset.unique.GameContext
import com.unciv.models.ruleset.unique.UniqueMap
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.stats.INamed
import yairm210.purity.annotations.Readonly
/** Data object for Religions */
class Religion() : INamed, IsPartOfGameInfoSerialization {
@ -77,11 +78,13 @@ class Religion() : INamed, IsPartOfGameInfoSerialization {
if (displayName != null) displayName!!
else name
@Readonly
private fun mapToExistingBeliefs(beliefs: Set<String>): Sequence<Belief> {
val rulesetBeliefs = gameInfo.ruleset.beliefs
return beliefs.asSequence().mapNotNull { rulesetBeliefs[it] }
}
@Readonly
fun getBeliefs(beliefType: BeliefType): Sequence<Belief> {
if (beliefType == BeliefType.Any)
return mapToExistingBeliefs((founderBeliefs + followerBeliefs).toHashSet())
@ -104,20 +107,20 @@ class Religion() : INamed, IsPartOfGameInfoSerialization {
mapToExistingBeliefs(founderBeliefs).filter { it.type == BeliefType.Enhancer }
}
fun hasBelief(belief: String) = followerBeliefs.contains(belief) || founderBeliefs.contains(belief)
@Readonly fun hasBelief(belief: String) = followerBeliefs.contains(belief) || founderBeliefs.contains(belief)
fun isPantheon() = getBeliefs(BeliefType.Pantheon).any() && !isMajorReligion()
@Readonly fun isPantheon() = getBeliefs(BeliefType.Pantheon).any() && !isMajorReligion()
@Readonly fun isMajorReligion() = getBeliefs(BeliefType.Founder).any()
@Readonly fun isEnhancedReligion() = getBeliefs(BeliefType.Enhancer).any()
fun isMajorReligion() = getBeliefs(BeliefType.Founder).any()
fun isEnhancedReligion() = getBeliefs(BeliefType.Enhancer).any()
fun getFounder() = gameInfo.getCivilization(foundingCivName)
@Readonly fun getFounder() = gameInfo.getCivilization(foundingCivName)
@Readonly
fun matchesFilter(filter: String, state: GameContext = GameContext.IgnoreConditionals, civ: Civilization? = null): Boolean {
return MultiFilter.multiFilter(filter, { matchesSingleFilter(it, state, civ) })
}
@Readonly
private fun matchesSingleFilter(filter: String, state: GameContext = GameContext.IgnoreConditionals, civ: Civilization? = null): Boolean {
val foundingCiv = getFounder()
when (filter) {

View File

@ -478,10 +478,10 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
}
fun isRanged() = rangedStrength > 0
fun isMelee() = !isRanged() && strength > 0
@Readonly fun isRanged() = rangedStrength > 0
@Readonly fun isMelee() = !isRanged() && strength > 0
val isMilitary by lazy { isRanged() || isMelee() }
fun isCivilian() = !isMilitary
@Readonly fun isCivilian() = !isMilitary
val isLandUnit by lazy { type.isLandUnit() }
val isWaterUnit by lazy { type.isWaterUnit() }