From 694354af096b5ec649871964efb86e0c8bc1da4b Mon Sep 17 00:00:00 2001 From: Yair Morgenstern Date: Fri, 11 Jul 2025 14:00:39 +0300 Subject: [PATCH] Added Purity for readonly validation (#13600) * Added purity to check readonly-ness :) * Update build.gradle.kts --- build.gradle.kts | 14 +++++++++++++ .../models/ruleset/unique/Conditionals.kt | 4 +++- .../unciv/models/ruleset/unique/Countables.kt | 6 ++++++ .../com/unciv/models/ruleset/unique/Unique.kt | 6 ++++++ .../unciv/models/ruleset/unique/UniqueMap.kt | 21 ++++++++++++++----- .../unciv/models/translations/Translations.kt | 5 +++++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 342c859893..77769f836e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,9 +35,23 @@ plugins { // This is *with* gradle 8.2 downloaded according the project specs, no idea what that's about kotlin("multiplatform") version "1.9.24" kotlin("plugin.serialization") version "1.9.24" + id("io.github.yairm210.purity-plugin") version "0.0.15" apply(false) } allprojects { +// repositories{ // for local purity +// mavenLocal() +// } + + apply(plugin = "io.github.yairm210.purity-plugin") + configure{ + wellKnownPureFunctions = setOf("kotlin.internal.ir.CHECK_NOT_NULL") + wellKnownReadonlyFunctions = setOf( + "kotlin.collections.any", + "kotlin.collections.Iterator.hasNext" + ) + } + apply(plugin = "eclipse") apply(plugin = "idea") diff --git a/core/src/com/unciv/models/ruleset/unique/Conditionals.kt b/core/src/com/unciv/models/ruleset/unique/Conditionals.kt index ae926e0dae..e825a10bd4 100644 --- a/core/src/com/unciv/models/ruleset/unique/Conditionals.kt +++ b/core/src/com/unciv/models/ruleset/unique/Conditionals.kt @@ -8,6 +8,7 @@ import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.managers.ReligionState import com.unciv.models.ruleset.validation.ModCompatibility import com.unciv.models.stats.Stat +import org.jetbrains.annotations.Contract import kotlin.random.Random object Conditionals { @@ -18,7 +19,8 @@ object Conditionals { seed = seed * 31 + state.hashCode() return Random(seed).nextFloat() } - + + @Contract("readonly") @Suppress("purity") fun conditionalApplies( unique: Unique?, conditional: Unique, diff --git a/core/src/com/unciv/models/ruleset/unique/Countables.kt b/core/src/com/unciv/models/ruleset/unique/Countables.kt index ef192ece56..a1a2d862b2 100644 --- a/core/src/com/unciv/models/ruleset/unique/Countables.kt +++ b/core/src/com/unciv/models/ruleset/unique/Countables.kt @@ -8,6 +8,7 @@ import com.unciv.models.translations.equalsPlaceholderText import com.unciv.models.translations.fillPlaceholders import com.unciv.models.translations.getPlaceholderParameters import com.unciv.models.translations.getPlaceholderText +import org.jetbrains.annotations.Contract import org.jetbrains.annotations.VisibleForTesting /** @@ -257,6 +258,7 @@ enum class Countables( open val noPlaceholders = !text.contains('[') // Leave these in place only for the really simple cases + @Contract("readonly") open fun matches(parameterText: String) = if (noPlaceholders) parameterText == text else parameterText.equalsPlaceholderText(placeholderText) @@ -266,7 +268,9 @@ enum class Countables( /** This indicates whether a parameter *is of this countable type*, not *whether its parameters are correct* * E.g. "[fakeBuilding] Buildings" is obviously a countable of type "[buildingFilter] Buildings", therefore matches will return true. * But it has another problem, which is that the building filter is bad, so its getErrorSeverity will return "ruleset specific" */ + @Contract("readonly") open fun matches(parameterText: String, ruleset: Ruleset): Boolean = false + @Contract("readonly") abstract fun eval(parameterText: String, stateForConditionals: StateForConditionals): Int? open val documentationHeader get() = @@ -289,6 +293,7 @@ enum class Countables( getErrorSeverity(parameterText.getPlaceholderParameters().first(), ruleset) companion object { + @Contract("readonly") fun getMatching(parameterText: String, ruleset: Ruleset?) = Countables.entries .firstOrNull { if (it.matchesWithRuleset) @@ -296,6 +301,7 @@ enum class Countables( else it.matches(parameterText) } + @Contract("readonly") fun getCountableAmount(parameterText: String, stateForConditionals: StateForConditionals): Int? { val ruleset = stateForConditionals.gameInfo?.ruleset val countable = getMatching(parameterText, ruleset) ?: return null diff --git a/core/src/com/unciv/models/ruleset/unique/Unique.kt b/core/src/com/unciv/models/ruleset/unique/Unique.kt index 6a2bbd558e..803d3b0dc7 100644 --- a/core/src/com/unciv/models/ruleset/unique/Unique.kt +++ b/core/src/com/unciv/models/ruleset/unique/Unique.kt @@ -12,6 +12,7 @@ import com.unciv.models.translations.getModifiers import com.unciv.models.translations.getPlaceholderParameters import com.unciv.models.translations.getPlaceholderText import com.unciv.models.translations.removeConditionals +import org.jetbrains.annotations.Contract import kotlin.math.max @@ -47,7 +48,9 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s fun hasFlag(flag: UniqueFlag) = type != null && type.flags.contains(flag) fun isHiddenToUsers() = hasFlag(UniqueFlag.HiddenToUsers) || hasModifier(UniqueType.ModifierHiddenFromUsers) + @Contract("readonly") fun getModifiers(type: UniqueType) = modifiersMap[type] ?: emptyList() + @Contract("readonly") fun hasModifier(type: UniqueType) = modifiersMap.containsKey(type) fun isModifiedByGameSpeed() = hasModifier(UniqueType.ModifiedByGameSpeed) fun isModifiedByGameProgress() = hasModifier(UniqueType.ModifiedByGameProgress) @@ -81,6 +84,7 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s return conditionalsApply(StateForConditionals(civInfo, city)) } + @Contract("readonly") fun conditionalsApply(state: StateForConditionals): Boolean { if (state.ignoreConditionals) return true // Always allow Timed conditional uniques. They are managed elsewhere @@ -92,6 +96,7 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s return true } + @Contract("readonly") @Suppress("purity") private fun getUniqueMultiplier(stateForConditionals: StateForConditionals): Int { if (stateForConditionals == StateForConditionals.IgnoreMultiplicationForCaching) return 1 @@ -126,6 +131,7 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s } /** Multiplies the unique according to the multiplication conditionals */ + @Contract("readonly") fun getMultiplied(stateForConditionals: StateForConditionals): Sequence { val multiplier = getUniqueMultiplier(stateForConditionals) return EndlessSequenceOf(this).take(multiplier) diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueMap.kt b/core/src/com/unciv/models/ruleset/unique/UniqueMap.kt index 2ab611e9d3..050f8ad74d 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueMap.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueMap.kt @@ -1,5 +1,6 @@ package com.unciv.models.ruleset.unique +import org.jetbrains.annotations.Contract import java.util.* open class UniqueMap() { @@ -14,8 +15,6 @@ open class UniqueMap() { addUniques(uniques.asIterable()) } - fun isEmpty(): Boolean = innerUniqueMap.isEmpty() - /** Adds one [unique] unless it has a ConditionalTimedUnique conditional */ open fun addUnique(unique: Unique) { val existingArrayList = innerUniqueMap[unique.placeholderText] @@ -42,26 +41,33 @@ open class UniqueMap() { typedUniqueMap.clear() } - // Pure functions + @Contract("readonly") + fun isEmpty(): Boolean = innerUniqueMap.isEmpty() + @Contract("readonly") fun hasUnique(uniqueType: UniqueType, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueType).any { it.conditionalsApply(state) && !it.isTimedTriggerable } + @Contract("readonly") fun hasUnique(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueTag).any { it.conditionalsApply(state) && !it.isTimedTriggerable } - + + @Contract("readonly") fun hasTagUnique(tagUnique: String) = innerUniqueMap.containsKey(tagUnique) // 160ms vs 1000-1250ms/30s + @Contract("readonly") fun getUniques(uniqueType: UniqueType) = typedUniqueMap[uniqueType] ?.asSequence() ?: emptySequence() + @Contract("readonly") fun getUniques(uniqueTag: String) = innerUniqueMap[uniqueTag] ?.asSequence() ?: emptySequence() + @Contract("readonly") fun getMatchingUniques(uniqueType: UniqueType, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueType) // Same as .filter | .flatMap, but more cpu/mem performant (7.7 GB vs ?? for test) @@ -73,6 +79,7 @@ open class UniqueMap() { } } + @Contract("readonly") fun getMatchingUniques(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueTag) // Same as .filter | .flatMap, but more cpu/mem performant (7.7 GB vs ?? for test) @@ -83,16 +90,20 @@ open class UniqueMap() { else -> it.getMultiplied(state) } } - + + @Contract("readonly") fun hasMatchingUnique(uniqueType: UniqueType, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueType).any { it.conditionalsApply(state) } + @Contract("readonly") fun hasMatchingUnique(uniqueTag: String, state: StateForConditionals = StateForConditionals.EmptyState) = getUniques(uniqueTag) .any { it.conditionalsApply(state) } + @Contract("readonly") fun getAllUniques() = innerUniqueMap.values.asSequence().flatten() + @Contract("readonly") fun getTriggeredUniques(trigger: UniqueType, stateForConditionals: StateForConditionals, triggerFilter: (Unique) -> Boolean = { true }): Sequence { return getAllUniques().filter { unique -> diff --git a/core/src/com/unciv/models/translations/Translations.kt b/core/src/com/unciv/models/translations/Translations.kt index 5bebd4daaf..b7c375a9a7 100644 --- a/core/src/com/unciv/models/translations/Translations.kt +++ b/core/src/com/unciv/models/translations/Translations.kt @@ -12,6 +12,7 @@ import com.unciv.ui.components.fonts.DiacriticSupport import com.unciv.ui.components.fonts.FontRulesetIcons import com.unciv.utils.Log import com.unciv.utils.debug +import org.jetbrains.annotations.Contract import java.util.Locale import org.jetbrains.annotations.VisibleForTesting @@ -472,6 +473,7 @@ private fun String.translateIndividualWord(language: String, hideIcons: Boolean, * For example, a string like 'The city of [New [York]]' will return ['New [York]'], * allowing us to have nested translations! */ +@Contract("readonly") fun String.getPlaceholderParameters(): List { if (!this.contains('[')) return emptyList() @@ -492,6 +494,7 @@ fun String.getPlaceholderParameters(): List { return parameters } +@Contract("readonly") fun String.getPlaceholderText(): String { var stringToReturn = this.removeConditionals() val placeholderParameters = stringToReturn.getPlaceholderParameters() @@ -500,6 +503,7 @@ fun String.getPlaceholderText(): String { return stringToReturn } +@Contract("readonly") fun String.equalsPlaceholderText(str: String): Boolean { if (isEmpty()) return str.isEmpty() if (str.isEmpty()) return false // Empty strings have no .first() @@ -529,6 +533,7 @@ fun String.getModifiers(): List { return pointyBraceRegex.findAll(this).map { Unique(it.groups[1]!!.value) }.toList() } +@Contract("readonly") fun String.removeConditionals(): String { if (!this.contains('<')) return this // no need to regex search return this