Skip to content

Expressions as Countable #13218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
9 changes: 7 additions & 2 deletions core/src/com/unciv/models/ruleset/RulesetCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.unciv.models.metadata.GameParameters
import com.unciv.models.ruleset.validation.RulesetError
import com.unciv.models.ruleset.validation.RulesetErrorList
import com.unciv.models.ruleset.validation.RulesetErrorSeverity
import com.unciv.models.ruleset.validation.UniqueValidator
import com.unciv.models.ruleset.validation.getRelativeTextDistance
import com.unciv.utils.Log
import com.unciv.utils.debug
Expand Down Expand Up @@ -59,8 +60,12 @@ object RulesetCache : HashMap<String, Ruleset>() {
// For extension mods which use references to base ruleset objects, the parameter type
// errors are irrelevant - the checker ran without a base ruleset
val logFilter: (RulesetError) -> Boolean =
if (modRuleset.modOptions.isBaseRuleset) { { it.errorSeverityToReport > RulesetErrorSeverity.WarningOptionsOnly } }
else { { it.errorSeverityToReport > RulesetErrorSeverity.WarningOptionsOnly && !it.text.contains("does not fit parameter type") } }
if (modRuleset.modOptions.isBaseRuleset) {
{ it.errorSeverityToReport > RulesetErrorSeverity.WarningOptionsOnly }
} else {
{ it.errorSeverityToReport > RulesetErrorSeverity.WarningOptionsOnly &&
!it.text.contains(UniqueValidator.whichDoesNotFitParameterType) }
}
if (modLinksErrors.any(logFilter)) {
debug(
"checkModLinks errors: %s",
Expand Down
157 changes: 131 additions & 26 deletions core/src/com/unciv/models/ruleset/unique/Countables.kt
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
package com.unciv.models.ruleset.unique

import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.unique.expressions.Expressions
import com.unciv.models.stats.Stat
import com.unciv.models.translations.equalsPlaceholderText
import com.unciv.models.translations.getPlaceholderParameters
import com.unciv.models.translations.getPlaceholderText
import com.unciv.utils.Log
import org.jetbrains.annotations.VisibleForTesting
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

/**
* Prototype for each new [Countables] instance, core functionality, to ensure a baseline.
*
* Notes:
* - Each instance ***must*** implement _either_ overload of [matches] and indicate which one via [matchesWithRuleset].
* - [matches] is used to look up which instance implements a given string, **without** validating its placeholders.
* It can be called with or without a ruleset. The ruleset is **only** to be used if there is no selective pattern
* to detect when a specific countable is "responsible" for a certain input, and for these, when `matches` is called
* without a ruleset, they must return `MatchResult.No` (Example below: TileResource).
* - [getErrorSeverity] is responsible for validating placeholders, _and can assume [matches] was successful_.
* - Override [getKnownValuesForAutocomplete] only if a sensible number of suggestions is obvious.
*/
interface ICountable {
fun matches(parameterText: String): Boolean = false
val matchesWithRuleset: Boolean
get() = false
fun matches(parameterText: String, ruleset: Ruleset): Boolean = false
/** Supports `MatchResult(true)`to get [Yes], MatchResult(false)`to get [No], or MatchResult(null)`to get [Maybe] */
enum class MatchResult {
No {
override fun isOK(strict: Boolean) = false
},
Maybe {
override fun isOK(strict: Boolean) = !strict
},
Yes {
override fun isOK(strict: Boolean) = true
}
;
abstract fun isOK(strict: Boolean): Boolean
companion object {
operator fun invoke(bool: Boolean?) = when(bool) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please just call this 'from' without operator overloading.
When you're young it's nice to experiment, it's fine to be operator-curious, but I think we're both past that phase :)

true -> Yes
false -> No
else -> Maybe
}
}
}

fun matches(parameterText: String, ruleset: Ruleset? = null): MatchResult = MatchResult.No
fun eval(parameterText: String, stateForConditionals: StateForConditionals): Int?
fun getKnownValuesForAutocomplete(ruleset: Ruleset): Set<String> = emptySet()
fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity?
fun getDeprecationAnnotation(): Deprecated?
}

/**
Expand Down Expand Up @@ -54,7 +79,7 @@ enum class Countables(
) : ICountable {
Integer {
override val documentationHeader = "Integer constant - any positive or negative integer number"
override fun matches(parameterText: String) = parameterText.toIntOrNull() != null
override fun matches(parameterText: String, ruleset: Ruleset?) = ICountable.MatchResult(parameterText.toIntOrNull() != null)
override fun eval(parameterText: String, stateForConditionals: StateForConditionals) = parameterText.toIntOrNull()
},

Expand All @@ -80,7 +105,7 @@ enum class Countables(
Stats {
override val documentationHeader = "Stat name (${Stat.names().niceJoin()})"
override val documentationStrings = listOf("gets the stat *reserve*, not the amount per turn (can be city stats or civilization stats, depending on where the unique is used)")
override fun matches(parameterText: String) = Stat.isStat(parameterText)
override fun matches(parameterText: String, ruleset: Ruleset?) = ICountable.MatchResult(Stat.isStat(parameterText))
override fun eval(parameterText: String, stateForConditionals: StateForConditionals): Int? {
val relevantStat = Stat.safeValueOf(parameterText) ?: return null
// This one isn't covered by City.getStatReserve or Civilization.getStatReserve but should be available here
Expand Down Expand Up @@ -164,8 +189,7 @@ enum class Countables(
"For example: If a unique is placed on a building, then the retrieved resources will be of the city. If placed on a policy, they will be of the civilization.",
"This can make a difference for e.g. local resources, which are counted per city."
)
override val matchesWithRuleset = true
override fun matches(parameterText: String, ruleset: Ruleset) = parameterText in ruleset.tileResources
override fun matches(parameterText: String, ruleset: Ruleset?) = ICountable.MatchResult(ruleset?.tileResources?.containsKey(parameterText))
override fun eval(parameterText: String, stateForConditionals: StateForConditionals) =
stateForConditionals.getResourceAmount(parameterText)
override fun getKnownValuesForAutocomplete(ruleset: Ruleset) = ruleset.tileResources.keys
Expand All @@ -178,17 +202,44 @@ enum class Countables(
val civilizations = stateForConditionals.gameInfo?.civilizations ?: return null
return civilizations.count { it.isAlive() && it.isCityState }
}
},

@InDevelopment("@AutumnPizazz", eta = "2025-06-30")
Expression {
override val noPlaceholders = false

private val engine = Expressions()

override fun matches(parameterText: String, ruleset: Ruleset?) =
engine.matches(parameterText, ruleset)
override fun eval(parameterText: String, stateForConditionals: StateForConditionals): Int? =
engine.eval(parameterText, stateForConditionals)
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? =
engine.getErrorSeverity(parameterText, ruleset)

override fun getKnownValuesForAutocomplete(ruleset: Ruleset) = emptySet<String>()

override val documentationHeader = "Evaluate expressions!"
override val documentationStrings = listOf(
"Expressions support `+`, `-`, `*`, `/`, `%`, `^` and `log` as binary operators.",
"Operands can be floating point constants or other countables in square brackets",
"..."
)
}
;
; //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

//region ' class-wide elements and ICountable'
val placeholderText = text.getPlaceholderText()

@VisibleForTesting
open val noPlaceholders = !text.contains('[')

// Leave these in place only for the really simple cases
override fun matches(parameterText: String) = if (noPlaceholders) parameterText == text
override fun matches(parameterText: String, ruleset: Ruleset?) = ICountable.MatchResult(
if (noPlaceholders) parameterText == text
else parameterText.equalsPlaceholderText(placeholderText)
)

override fun getKnownValuesForAutocomplete(ruleset: Ruleset) = setOf(text)

open val documentationHeader get() =
Expand All @@ -197,25 +248,63 @@ enum class Countables(
/** Leave this only for Countables without any parameters - they can rely on [matches] having validated enough */
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? = null

override fun getDeprecationAnnotation(): Deprecated? = declaringJavaClass.getField(name).getAnnotation(Deprecated::class.java)

/** Helper for Countables with exactly one placeholder that is a UniqueParameterType */
protected fun UniqueParameterType.getTranslatedErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? {
// This calls UniqueParameterType's getErrorSeverity:
val severity = getErrorSeverity(parameterText.getPlaceholderParameters().first(), ruleset)
// Map PossibleFilteringUnique to RulesetSpecific otherwise those mistakes would be hidden later on in RulesetValidator
return when {
severity != UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique -> severity
matchesWithRuleset -> UniqueType.UniqueParameterErrorSeverity.RulesetSpecific
else -> UniqueType.UniqueParameterErrorSeverity.RulesetInvariant
else -> UniqueType.UniqueParameterErrorSeverity.RulesetSpecific
}
}
//endregion

//region ' Deprecation and InDevelopment'
fun getDeprecationAnnotation(): Deprecated? = getDeprecatedAnnotation() ?: getDeprecationFromInDevelopment()

/**
* This annotation marks a [Countables] instance as active for debug builds only.
* - If you add both @Deprecated and @InDevelopment to the same instance, the validator will only see the @Deprecated one,
* while @InDevelopment still controls whether it si active (evaluated at all).
* @param developer - the responsible dev as `@` plus github account
* @param eta - An expiration date in the form 'yyyy-mm-dd', UTC, after which the instance is treated as deprecated **and** inactive
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation class InDevelopment(val developer: String, val eta: String)

private fun getDeprecatedAnnotation(): Deprecated? = declaringJavaClass.getField(name).getAnnotation(Deprecated::class.java)
private fun getInDevelopmentAnnotation(): InDevelopment? = declaringJavaClass.getField(name).getAnnotation(InDevelopment::class.java)
private fun InDevelopment.isExpired(): Boolean {
val etaDate = LocalDate.parse(eta, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
return etaDate.isBefore(LocalDate.now(ZoneOffset.UTC))
}
private fun isInactive(): Boolean {
val inDev = getInDevelopmentAnnotation() ?: return false
if (inDev.isExpired()) return true
return Log.backend.isRelease()
}
private fun getDeprecationFromInDevelopment(): Deprecated? =
if (getInDevelopmentAnnotation()?.isExpired() == true) Deprecated("InDevelopment ETA has expired") else null
//endregion

companion object {
fun getMatching(parameterText: String, ruleset: Ruleset?) = Countables.entries
private fun getMatching(parameterText: String, ruleset: Ruleset?) = Countables.entries
.filter {
if (it.matchesWithRuleset)
ruleset != null && it.matches(parameterText, ruleset!!)
else it.matches(parameterText)
!it.isInactive() && it.matches(parameterText, ruleset).isOK(strict = true)
}

fun getBestMatching(parameterText: String, ruleset: Ruleset?): Pair<Countables?, ICountable.MatchResult> =
Countables.entries
.filter {
!it.isInactive() && it.matches(parameterText, ruleset).isOK(strict = true)
}.mapNotNull {
val result = it.matches(parameterText, ruleset)
if (result.isOK(strict = false)) it to result else null
}.minByOrNull { it.second }
?: Pair(null, ICountable.MatchResult.No)

fun getCountableAmount(parameterText: String, stateForConditionals: StateForConditionals): Int? {
val ruleset = stateForConditionals.gameInfo?.ruleset
for (countable in Countables.getMatching(parameterText, ruleset)) {
Expand All @@ -237,13 +326,29 @@ enum class Countables(
}

fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? {
var result = UniqueType.UniqueParameterErrorSeverity.RulesetInvariant
for (countable in Countables.getMatching(parameterText, ruleset)) {
// If any Countable is happy, we're happy
result = countable.getErrorSeverity(parameterText, ruleset) ?: return null
var result: UniqueType.UniqueParameterErrorSeverity? = null
for (countable in Countables.entries) {
if (countable.isInactive()) continue
val thisResult = when (countable.matches(parameterText, ruleset)) {
ICountable.MatchResult.No -> continue
ICountable.MatchResult.Yes ->
countable.getErrorSeverity(parameterText, ruleset)
// If any Countable is happy, we're happy: Should be the only path to return `null`, meaning perfectly OK
?: return null
else -> UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique
}
if (result == null || thisResult > result) result = thisResult
}
// return last result or default for simplicity - could do a max() instead
return result
// return worst result - or if the loop found nothing, max severity
return result ?: UniqueType.UniqueParameterErrorSeverity.RulesetInvariant
}

/** Get deprecated [Countables] with their [Deprecated] object matching a [parameterText], for `UniqueValidator` */
fun getDeprecatedCountablesMatching(parameterText: String): List<Pair<Countables, Deprecated>> =
Countables.entries.filter {
!it.isInactive() && it.matches(parameterText, null).isOK(strict = false)
}.mapNotNull { countable ->
countable.getDeprecationAnnotation()?.let { countable to it }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.unciv.models.ruleset.unique.expressions

import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.unique.ICountable
import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.UniqueType
import kotlin.math.roundToInt

class Expressions : ICountable {
override fun matches(parameterText: String, ruleset: Ruleset?) =
parse(parameterText, ruleset).severity

override fun eval(parameterText: String, stateForConditionals: StateForConditionals): Int? {
val node = parse(parameterText, null).node ?: return null
return node.eval(stateForConditionals).roundToInt()
}

override fun getErrorSeverity(parameterText: String, ruleset: Ruleset) =
when(parse(parameterText, ruleset).severity) {
ICountable.MatchResult.No -> UniqueType.UniqueParameterErrorSeverity.RulesetInvariant
ICountable.MatchResult.Maybe -> UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique
ICountable.MatchResult.Yes -> null
}

private data class ParseResult(val severity: ICountable.MatchResult, val node: Node?)

companion object {
private val cache: MutableMap<String, ParseResult> = mutableMapOf()

private fun parse(parameterText: String, ruleset: Ruleset?): ParseResult = cache.getOrPut(parameterText) {
try {
val node = Parser.parse(parameterText, ruleset)
ParseResult(ICountable.MatchResult.Yes, node)
} catch (ex: Parser.ParsingError) {
ParseResult(ex.severity, null)
}
}
}
}
35 changes: 35 additions & 0 deletions core/src/com/unciv/models/ruleset/unique/expressions/Node.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.unciv.models.ruleset.unique.expressions

import com.unciv.models.ruleset.unique.Countables
import com.unciv.models.ruleset.unique.StateForConditionals

internal sealed interface Node {
fun eval(context: StateForConditionals): Double

// All elements below are not members, they're nested for namespace notation and common visibility
// All toString() are for debugging only

interface Constant : Node, Tokenizer.Token {
val value: Double
override fun eval(context: StateForConditionals) = value
}

class NumericConstant(override val value: Double) : Constant {
override fun toString() = value.toString()
}

class UnaryOperation(private val operator: Operator.Unary, private val operand: Node): Node {
override fun eval(context: StateForConditionals): Double = operator.implementation(operand.eval(context))
override fun toString() = "($operator $operand)"
}

class BinaryOperation(private val operator: Operator.Binary, private val left: Node, private val right: Node): Node {
override fun eval(context: StateForConditionals): Double = operator.implementation(left.eval(context), right.eval(context))
override fun toString() = "($left $operator $right)"
}

class Countable(private val countable: Countables, private val parameterText: String): Node, Tokenizer.Token {
override fun eval(context: StateForConditionals): Double = countable.eval(parameterText, context)?.toDouble() ?: 0.0
override fun toString() = "[Countables.${countable.name}: $parameterText]"
}
}
Loading
Loading