chore(purity): QuestManager

This commit is contained in:
yairm210 2025-08-06 23:19:18 +03:00
parent 10f0a79ac9
commit 0c4b9cbb85
10 changed files with 60 additions and 27 deletions

View File

@ -62,6 +62,8 @@ allprojects {
"java.util.BitSet.get", // moved
"kotlin.collections.getValue", // moved
"kotlin.collections.randomOrNull",
"kotlin.collections.Collection.isEmpty",
"kotlin.collections.subtract"
)
wellKnownPureClasses = setOf<String>(
)

View File

@ -266,6 +266,7 @@ class CityConstructions : IsPartOfGameInfoSerialization {
else 0
}
@Readonly
fun getRemainingWork(constructionName: String, useStoredProduction: Boolean = true): Int {
val constr = getConstruction(constructionName)
return when {

View File

@ -380,11 +380,11 @@ class Civilization : IsPartOfGameInfoSerialization {
var cityStatePersonality: CityStatePersonality = CityStatePersonality.Neutral
var cityStateResource: String? = null
var cityStateUniqueUnit: String? = null // Unique unit for militaristic city state. Might still be null if there are no appropriate units
fun hasMetCivTerritory(otherCiv: Civilization): Boolean =
@Readonly fun hasMetCivTerritory(otherCiv: Civilization): Boolean =
otherCiv.getCivTerritory().any { gameInfo.tileMap[it].isExplored(this) }
@Readonly fun getCompletedPolicyBranchesCount(): Int = policies.adoptedPolicies.count { Policy.isBranchCompleteByName(it) }
private fun getCivTerritory() = cities.asSequence().flatMap { it.tiles.asSequence() }
@Readonly private fun getCivTerritory() = cities.asSequence().flatMap { it.tiles.asSequence() }
@Readonly
fun getPreferredVictoryTypes(): List<String> {
@ -463,7 +463,7 @@ class Civilization : IsPartOfGameInfoSerialization {
return newResourceSupplyList
}
fun isCapitalConnectedToCity(city: City): Boolean = cache.citiesConnectedToCapitalToMediums.keys.contains(city)
@Readonly fun isCapitalConnectedToCity(city: City): Boolean = cache.citiesConnectedToCapitalToMediums.keys.contains(city)
/**

View File

@ -187,6 +187,7 @@ class CityStateFunctions(val civInfo: Civilization) {
)
}
@Readonly
fun influenceGainedByGift(donorCiv: Civilization, giftAmount: Int): Int {
// https://github.com/Gedemon/Civ5-DLL/blob/aa29e80751f541ae04858b6d2a2c7dcca454201e/CvGameCoreDLL_Expansion1/CvMinorCivAI.cpp
// line 8681 and below

View File

@ -7,6 +7,7 @@ import com.unciv.logic.civilization.NotificationCategory
import com.unciv.models.Counter
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.MayaCalendar
import yairm210.purity.annotations.Readonly
// todo: Great Admiral?
@ -46,11 +47,12 @@ class GreatPersonManager : IsPartOfGameInfoSerialization {
return toReturn
}
@Readonly
private fun getPoolKey(greatPerson: String) = civInfo.getEquivalentUnit(greatPerson)
.getMatchingUniques(UniqueType.GPPointPool)
// An empty string is used to indicate the Unique wasn't found
.firstOrNull()?.params?.get(0) ?: ""
fun getPointsRequiredForGreatPerson(greatPerson: String): Int {
val key = getPoolKey(greatPerson)
if (pointsForNextGreatPersonCounter[key] == 0) {
@ -102,12 +104,14 @@ class GreatPersonManager : IsPartOfGameInfoSerialization {
}
/** Get Great People specific to this manager's Civilization, already filtered by `isHiddenBySettings` */
@Readonly
fun getGreatPeople() = civInfo.gameInfo.ruleset.units.values.asSequence()
.filter { it.isGreatPerson }
.map { civInfo.getEquivalentUnit(it.name) }
.filterNot { it.isUnavailableBySettings(civInfo.gameInfo) }
.toHashSet()
@Readonly
fun getGreatPersonPointsForNextTurn(): Counter<String> {
val greatPersonPoints = Counter<String>()
for (city in civInfo.cities) greatPersonPoints.add(city.getGreatPersonPoints())

View File

@ -33,6 +33,7 @@ import com.unciv.models.translations.getPlaceholderParameters
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.toPercent
import com.unciv.utils.randomWeighted
import yairm210.purity.annotations.Readonly
import kotlin.random.Random
class QuestManager : IsPartOfGameInfoSerialization {
@ -80,21 +81,24 @@ class QuestManager : IsPartOfGameInfoSerialization {
private var unitsKilledFromCiv: HashMap<String, HashMap<String, Int>> = HashMap()
/** Returns true if [civ] have active quests for [challenger] */
fun haveQuestsFor(challenger: Civilization): Boolean = getAssignedQuestsFor(challenger.civName).any()
@Readonly fun haveQuestsFor(challenger: Civilization): Boolean = getAssignedQuestsFor(challenger.civName).any()
/** Access all assigned Quests for [civName] */
@Readonly
fun getAssignedQuestsFor(civName: String) =
assignedQuests.asSequence().filter { it.assignee == civName }
/** Access all assigned Quests of "type" [questName] */
// Note if we decide to cache an index of these (such as `assignedQuests.groupBy { it.questNameInstance }`), this accessor would simplify the transition
@Readonly
private fun getAssignedQuestsOfName(questName: QuestName) =
assignedQuests.asSequence().filter { it.questNameInstance == questName }
/** Returns true if [civ] has asked anyone to conquer [target] */
fun wantsDead(target: String): Boolean = getAssignedQuestsOfName(QuestName.ConquerCityState).any { it.data1 == target }
@Readonly fun wantsDead(target: String): Boolean = getAssignedQuestsOfName(QuestName.ConquerCityState).any { it.data1 == target }
/** Returns the influence multiplier for [donor] from a Investment quest that [civ] might have (assumes only one) */
@Readonly
fun getInvestmentMultiplier(donor: String): Float {
val investmentQuest = getAssignedQuestsOfName(QuestName.Invest).firstOrNull { it.assignee == donor }
?: return 1f
@ -367,12 +371,14 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
/** Returns true if [civ] can assign a quest to [challenger] */
@Readonly
private fun canAssignAQuestTo(challenger: Civilization): Boolean {
return !challenger.isDefeated() && challenger.isMajorCiv() &&
civ.knows(challenger) && !civ.isAtWarWith(challenger)
}
/** Returns true if the [quest] can be assigned to [challenger] */
@Readonly
private fun isQuestValid(quest: Quest, challenger: Civilization): Boolean {
if (!canAssignAQuestTo(challenger))
return false
@ -403,6 +409,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
}
@Readonly
private fun isRouteQuestValid(challenger: Civilization): Boolean {
if (challenger.cities.isEmpty()) return false
if (challenger.isCapitalConnectedToCity(civ.getCapital()!!)) return false
@ -414,6 +421,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
}
@Readonly
private fun isDenounceCivQuestValid(challenger: Civilization, mostRecentBully: String?): Boolean {
return mostRecentBully != null
&& challenger.knows(mostRecentBully)
@ -424,6 +432,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
/** Returns true if the [assignedQuest] is successfully completed */
@Readonly
private fun isComplete(assignedQuest: AssignedQuest): Boolean {
val assignee = civ.gameInfo.getCivilization(assignedQuest.assignee)
return when (assignedQuest.questNameInstance) {
@ -441,6 +450,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
/** Returns true if the [assignedQuest] request cannot be fulfilled anymore */
@Readonly
private fun isObsolete(assignedQuest: AssignedQuest): Boolean {
val assignee = civ.gameInfo.getCivilization(assignedQuest.assignee)
return when (assignedQuest.questNameInstance) {
@ -489,6 +499,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
}
/** Returns the score for the [assignedQuest] */
@Readonly
private fun getScoreForQuest(assignedQuest: AssignedQuest): Int {
val assignee = civ.gameInfo.getCivilization(assignedQuest.assignee)
@ -545,6 +556,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
* Tied leaders are separated by ", " - translators cannot influence this, sorry.
* @param inquiringAssignedQuest Determines ["type"][AssignedQuest.questNameInstance] to find all competitors in [assignedQuests] and [viewing civ][AssignedQuest.assignee].
*/
@Readonly
fun getScoreStringForGlobalQuest(inquiringAssignedQuest: AssignedQuest): String {
require(inquiringAssignedQuest.assigner == civ.civName)
require(inquiringAssignedQuest.isGlobal())
@ -654,7 +666,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/** Gets notified when [killed]'s military unit was killed by [killer], for war with major pseudo-quest */
fun militaryUnitKilledBy(killer: Civilization, killed: Civilization) {
if (!warWithMajorActive(killed)) return
if (!isWarWithMajorActive(killed)) return
// No credit if we're at war or haven't met
if (!civ.knows(killer) || civ.isAtWarWith(killer)) return
@ -705,14 +717,11 @@ class QuestManager : IsPartOfGameInfoSerialization {
unitsKilledFromCiv.remove(attacker.civName)
}
fun warWithMajorActive(target: Civilization): Boolean {
return unitsToKillForCiv.containsKey(target.civName)
}
@Readonly fun isWarWithMajorActive(target: Civilization): Boolean = unitsToKillForCiv.containsKey(target.civName)
fun unitsToKill(target: Civilization): Int {
return unitsToKillForCiv[target.civName] ?: 0
}
@Readonly fun unitsToKill(target: Civilization): Int = unitsToKillForCiv[target.civName] ?: 0
@Readonly
fun unitsKilledSoFar(target: Civilization, viewingCiv: Civilization): Int {
val killMap = unitsKilledFromCiv[target.civName] ?: return 0
return killMap[viewingCiv.civName] ?: 0
@ -734,6 +743,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/**
* Returns the weight of the [questName], depends on city state trait and personality
*/
@Readonly
private fun getQuestWeight(questName: String): Float {
var weight = 1f
val quest = ruleset.quests[questName] ?: return 0f
@ -751,6 +761,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
* Returns a random [Tile] containing a Barbarian encampment within 8 tiles of [civ]
* to be destroyed
*/
@Readonly
private fun getBarbarianEncampmentForQuest(): Tile? {
val encampments = civ.getCapital()!!.getCenterTile().getTilesInDistance(8)
.filter { it.improvement == Constants.barbarianEncampment }.toList()
@ -764,6 +775,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
* by the [civ] and the [challenger], and must be viewable by the [challenger];
* if none exists, it returns null.
*/
@Readonly
private fun getResourceForQuest(challenger: Civilization): TileResource? {
val ownedByCityStateResources = civ.detailedCivResources.map { it.resource }
val ownedByMajorResources = challenger.detailedCivResources.map { it.resource }
@ -781,8 +793,9 @@ class QuestManager : IsPartOfGameInfoSerialization {
return notOwnedResources.randomOrNull()
}
@Readonly
private fun getWonderToBuildForQuest(challenger: Civilization): Building? {
fun isMoreThanAQuarterDone(city: City, buildingName: String) =
@Readonly fun isMoreThanAQuarterDone(city: City, buildingName: String) =
city.cityConstructions.getWorkDone(buildingName) * 3 > city.cityConstructions.getRemainingWork(buildingName)
val wonders = ruleset.buildings.values
.filter { building ->
@ -803,6 +816,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/**
* Returns a random Natural Wonder not yet discovered by [challenger].
*/
@Readonly
private fun getNaturalWonderToFindForQuest(challenger: Civilization): String? {
val naturalWondersToFind = civ.gameInfo.tileMap.naturalWonders.subtract(challenger.naturalWonders)
@ -812,6 +826,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/**
* Returns a Great Person [BaseUnit] that is not owned by both the [challenger] and the [civ]
*/
@Readonly
private fun getGreatPersonForQuest(challenger: Civilization): BaseUnit? {
val ruleset = ruleset // omit if the accessor should be converted to a transient field
@ -835,6 +850,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
* Returns a random [Civilization] (major) that [challenger] has met, but whose territory he
* cannot see; if none exists, it returns null.
*/
@Readonly
private fun getCivilizationToFindForQuest(challenger: Civilization): Civilization? {
val civilizationsToFind = challenger.getKnownCivs()
.filter { it.isAlive() && it.isMajorCiv() && !challenger.hasMetCivTerritory(it) }
@ -846,6 +862,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/**
* Returns a city-state [Civilization] that [civ] wants to target for hostile quests
*/
@Readonly
private fun getCityStateTarget(challenger: Civilization): Civilization? {
val closestProximity = civ.gameInfo.getAliveCityStates()
.mapNotNull { civ.proximity[it.civName] }.filter { it != Proximity.None }.minByOrNull { it.ordinal }
@ -861,6 +878,7 @@ class QuestManager : IsPartOfGameInfoSerialization {
/** Returns a [Civilization] of the civ that most recently bullied [civ].
* Note: forgets after 20 turns has passed! */
@Readonly
private fun getMostRecentBully(): String? {
val bullies = civ.diplomacy.values.filter { it.hasFlag(DiplomacyFlags.Bullied) }
return bullies.maxByOrNull { it.getFlag(DiplomacyFlags.Bullied) }?.otherCivName
@ -892,17 +910,17 @@ class AssignedQuest(
questObject = quest ?: gameInfo.ruleset.quests[questName]!!
}
fun isIndividual(): Boolean = !isGlobal()
fun isGlobal(): Boolean = questObject.isGlobal()
@Readonly fun isIndividual(): Boolean = !isGlobal()
@Readonly fun isGlobal(): Boolean = questObject.isGlobal()
@Suppress("MemberVisibilityCanBePrivate")
fun doesExpire(): Boolean = questObject.duration > 0
fun isExpired(): Boolean = doesExpire() && getRemainingTurns() == 0
@Readonly fun doesExpire(): Boolean = questObject.duration > 0
@Readonly fun isExpired(): Boolean = doesExpire() && getRemainingTurns() == 0
@Suppress("MemberVisibilityCanBePrivate")
fun getDuration(): Int = (gameInfo.speed.modifier * questObject.duration).toInt()
fun getRemainingTurns(): Int = (assignedOnTurn + getDuration() - gameInfo.turns).coerceAtLeast(0)
fun getInfluence() = questObject.influence
@Readonly fun getDuration(): Int = (gameInfo.speed.modifier * questObject.duration).toInt()
@Readonly fun getRemainingTurns(): Int = (assignedOnTurn + getDuration() - gameInfo.turns).coerceAtLeast(0)
@Readonly fun getInfluence() = questObject.influence
fun getDescription(): String = questObject.description.fillPlaceholders(data1)
@Readonly fun getDescription(): String = questObject.description.fillPlaceholders(data1)
fun onClickAction() {
when (questNameInstance) {

View File

@ -2,6 +2,7 @@ package com.unciv.models.ruleset
import com.unciv.logic.civilization.Civilization
import com.unciv.models.stats.INamed
import yairm210.purity.annotations.Readonly
enum class QuestName(val value: String) {
Route("Route"),
@ -24,7 +25,7 @@ enum class QuestName(val value: String) {
None("")
;
companion object {
fun find(value: String) = values().firstOrNull { it.value == value } ?: None
fun find(value: String) = entries.firstOrNull { it.value == value } ?: None
}
}
@ -67,6 +68,6 @@ class Quest : INamed {
var weightForCityStateType = HashMap<String, Float>()
/** Checks if `this` is a Global quest */
fun isGlobal(): Boolean = type == QuestType.Global
fun isIndividual(): Boolean = !isGlobal()
@Readonly fun isGlobal(): Boolean = type == QuestType.Global
@Readonly fun isIndividual(): Boolean = !isGlobal()
}

View File

@ -371,6 +371,7 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
// This returns the name of the unit this tech upgrades this unit to,
// or null if there is no automatic upgrade at that tech.
@Readonly
fun automaticallyUpgradedInProductionToUnitByTech(techName: String): String? {
for (obsoleteTech: String in techsAtWhichAutoUpgradeInProduction())
if (obsoleteTech == techName)
@ -405,6 +406,7 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction {
}
}
@Readonly
fun getReplacedUnit(ruleset: Ruleset): BaseUnit {
return if (replaces == null) this
else ruleset.units[replaces!!]!!

View File

@ -11,6 +11,7 @@ import com.unciv.models.ruleset.unique.UniqueParameterType
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.fillPlaceholders
import yairm210.purity.annotations.Pure
import yairm210.purity.annotations.Readonly
/**
* All public methods dealing with how Mod authors can suppress RulesetValidator output.
@ -65,6 +66,7 @@ object Suppression {
else -> true
}
@Pure
private fun matchesFilter(error: RulesetError, filter: String): Boolean {
if (error.text == filter) return true
if (!filter.endsWith('*') || !filter.startsWith('*')) return false
@ -72,6 +74,7 @@ object Suppression {
}
/** Determine if [error] matches any suppression Unique in [ModOptions] or the [sourceObject], or any suppression modifier in [sourceUnique] */
@Readonly
internal fun isErrorSuppressed(
globalSuppressionFilters: Collection<String>,
sourceObject: IHasUniques?,
@ -81,6 +84,7 @@ object Suppression {
if (error.errorSeverityToReport >= RulesetErrorSeverity.Error) return false
if (sourceObject == null && globalSuppressionFilters.isEmpty()) return false
@Readonly
fun getWildcardFilter(unique: Unique) = unique.params[0].let {
if (it.startsWith('*')) it else "*$it*"
}

View File

@ -86,7 +86,7 @@ class CityStateDiplomacyTable(private val diplomacyScreen: DiplomacyScreen) {
diplomacyTable.add(getQuestTable(assignedQuest)).row()
}
for (target in otherCiv.getKnownCivs().filter { otherCiv.questManager.warWithMajorActive(it) && viewingCiv != it }) {
for (target in otherCiv.getKnownCivs().filter { otherCiv.questManager.isWarWithMajorActive(it) && viewingCiv != it }) {
diplomacyTable.addSeparator()
diplomacyTable.add(getWarWithMajorTable(target, otherCiv)).row()
}