"for every [countable]" unique modifier (#11641)

* "for every [countable]" unique modifier

* detekt fix

* Test city-unique edge cases
This commit is contained in:
Yair Morgenstern
2024-05-27 12:32:12 +03:00
committed by GitHub
parent ba56374ee2
commit 11cee77fb9
10 changed files with 174 additions and 114 deletions

View File

@ -483,7 +483,7 @@ class City : IsPartOfGameInfoSerialization {
+ religion.getUniques().filter { it.type == uniqueType }
).filter {
!it.isTimedTriggerable && it.conditionalsApply(stateForConditionals)
}
}.flatMap { it.getMultiplied(stateForConditionals) }
}
// Uniques special to this city
@ -491,6 +491,7 @@ class City : IsPartOfGameInfoSerialization {
val uniques = cityConstructions.builtBuildingUniqueMap.getUniques(uniqueType).filter { it.isLocalEffect } +
religion.getUniques().filter { it.type == uniqueType }
return if (uniques.any()) uniques.filter { !it.isTimedTriggerable && it.conditionalsApply(stateForConditionals) }
.flatMap { it.getMultiplied(stateForConditionals) }
else uniques
}
@ -499,7 +500,7 @@ class City : IsPartOfGameInfoSerialization {
val uniques = cityConstructions.builtBuildingUniqueMap.getUniques(uniqueType)
// Memory performance showed that this function was very memory intensive, thus we only create the filter if needed
return if (uniques.any()) uniques.filter { !it.isLocalEffect && !it.isTimedTriggerable
&& it.conditionalsApply(stateForConditionals) }
&& it.conditionalsApply(stateForConditionals) }.flatMap { it.getMultiplied(stateForConditionals) }
else uniques
}

View File

@ -2,7 +2,6 @@ package com.unciv.models.ruleset.unique
import com.unciv.logic.GameInfo
import com.unciv.logic.battle.CombatAction
import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.managers.ReligionState
@ -20,62 +19,31 @@ object Conditionals {
if (condition.type?.targetTypes?.any { it.modifierType == UniqueTarget.ModifierType.Other } == true)
return true // not a filtering condition, includes e.g. ModifierHiddenFromUsers
val relevantUnit by lazy {
if (state.ourCombatant != null && state.ourCombatant is MapUnitCombatant) state.ourCombatant.unit
else state.unit
}
val relevantTile by lazy { state.attackedTile
?: state.tile
// We need to protect against conditionals checking tiles for units pre-placement - see #10425, #10512
?: relevantUnit?.run { if (hasTile()) getTile() else null }
?: state.city?.getCenterTile()
}
val relevantCity by lazy {
state.city
?: relevantTile?.getCity()
}
val relevantCiv by lazy {
state.civInfo ?:
relevantCity?.civ ?:
relevantUnit?.civ
}
val gameInfo by lazy { relevantCiv?.gameInfo }
val stateBasedRandom by lazy { Random(state.hashCode() * 31 + (gameInfo?.turns?.hashCode() ?: 0)) }
fun getResourceAmount(resourceName: String): Int {
if (relevantCity != null) return relevantCity!!.getAvailableResourceAmount(resourceName)
if (relevantCiv != null) return relevantCiv!!.getResourceAmount(resourceName)
return 0
}
val stateBasedRandom by lazy { Random(state.hashCode() * 31 + (state.gameInfo?.turns?.hashCode() ?: 0)) }
/** Helper to simplify conditional tests requiring gameInfo */
fun checkOnGameInfo(predicate: (GameInfo.() -> Boolean)): Boolean {
if (gameInfo == null) return false
return gameInfo!!.predicate()
if (state.gameInfo == null) return false
return state.gameInfo!!.predicate()
}
/** Helper to simplify conditional tests requiring a Civilization */
fun checkOnCiv(predicate: (Civilization.() -> Boolean)): Boolean {
if (relevantCiv == null) return false
return relevantCiv!!.predicate()
if (state.relevantCiv == null) return false
return state.relevantCiv!!.predicate()
}
/** Helper to simplify conditional tests requiring a City */
fun checkOnCity(predicate: (City.() -> Boolean)): Boolean {
if (relevantCity == null) return false
return relevantCity!!.predicate()
if (state.relevantCity == null) return false
return state.relevantCity!!.predicate()
}
/** Helper to simplify the "compare civ's current era with named era" conditions */
fun compareEra(eraParam: String, compare: (civEra: Int, paramEra: Int) -> Boolean): Boolean {
if (gameInfo == null) return false
val era = gameInfo!!.ruleset.eras[eraParam] ?: return false
return compare(relevantCiv!!.getEraNumber(), era.eraNumber)
if (state.gameInfo == null) return false
val era = state.gameInfo!!.ruleset.eras[eraParam] ?: return false
return compare(state.relevantCiv!!.getEraNumber(), era.eraNumber)
}
/** Helper for ConditionalWhenAboveAmountStatResource and its below counterpart */
@ -86,50 +54,27 @@ object Conditionals {
modifyByGameSpeed: Boolean = false,
compare: (current: Int, lowerLimit: Float, upperLimit: Float) -> Boolean
): Boolean {
if (gameInfo == null) return false
var gameSpeedModifier = if (modifyByGameSpeed) gameInfo!!.speed.modifier else 1f
if (state.gameInfo == null) return false
var gameSpeedModifier = if (modifyByGameSpeed) state.gameInfo!!.speed.modifier else 1f
if (gameInfo!!.ruleset.tileResources.containsKey(resourceOrStatName))
return compare(getResourceAmount(resourceOrStatName), lowerLimit * gameSpeedModifier, upperLimit * gameSpeedModifier)
if (state.gameInfo!!.ruleset.tileResources.containsKey(resourceOrStatName))
return compare(state.getResourceAmount(resourceOrStatName), lowerLimit * gameSpeedModifier, upperLimit * gameSpeedModifier)
val stat = Stat.safeValueOf(resourceOrStatName)
?: return false
val statReserve = if (relevantCity != null) relevantCity!!.getStatReserve(stat) else relevantCiv!!.getStatReserve(stat)
val statReserve = if (state.relevantCity != null) state.relevantCity!!.getStatReserve(stat) else state.relevantCiv!!.getStatReserve(stat)
gameSpeedModifier = if (modifyByGameSpeed) gameInfo!!.speed.statCostModifiers[stat]!! else 1f
gameSpeedModifier = if (modifyByGameSpeed) state.gameInfo!!.speed.statCostModifiers[stat]!! else 1f
return compare(statReserve, lowerLimit * gameSpeedModifier, upperLimit * gameSpeedModifier)
}
fun getCountableAmount(countable: String): Float? {
if (countable.toFloatOrNull() != null) return countable.toFloat()
val relevantStat = Stat.safeValueOf(countable)
if (relevantStat != null) {
return if (relevantCity != null) {
relevantCity!!.getStatReserve(relevantStat).toFloat()
} else if (relevantStat in Stat.statsWithCivWideField && relevantCiv != null) {
relevantCiv!!.getStatReserve(relevantStat).toFloat()
} else {
null
}
}
if (gameInfo == null) return null
if (countable == "year") return gameInfo!!.getYear(gameInfo!!.turns).toFloat()
if (gameInfo!!.ruleset.tileResources.containsKey(countable))
return getResourceAmount(countable).toFloat()
return null
}
fun compareCountables(
first: String,
second: String,
compare: (first: Float, second: Float) -> Boolean): Boolean {
compare: (first: Int, second: Int) -> Boolean): Boolean {
val firstNumber = getCountableAmount(first)
val secondNumber = getCountableAmount(second)
val firstNumber = Countables.getCountableAmount(first, state)
val secondNumber = Countables.getCountableAmount(second, state)
return if (firstNumber != null && secondNumber != null)
compare(firstNumber, secondNumber)
@ -138,11 +83,11 @@ object Conditionals {
}
fun compareCountables(first: String, second: String, third: String,
compare: (first: Float, second: Float, third: Float) -> Boolean): Boolean {
compare: (first: Int, second: Int, third: Int) -> Boolean): Boolean {
val firstNumber = getCountableAmount(first)
val secondNumber = getCountableAmount(second)
val thirdNumber = getCountableAmount(third)
val firstNumber = Countables.getCountableAmount(first, state)
val secondNumber = Countables.getCountableAmount(second, state)
val thirdNumber = Countables.getCountableAmount(third, state)
return if (firstNumber != null && secondNumber != null && thirdNumber != null)
compare(firstNumber, secondNumber, thirdNumber)
@ -162,8 +107,8 @@ object Conditionals {
UniqueType.ConditionalCivFilter -> checkOnCiv { matchesFilter(condition.params[0]) }
UniqueType.ConditionalWar -> checkOnCiv { isAtWar() }
UniqueType.ConditionalNotWar -> checkOnCiv { !isAtWar() }
UniqueType.ConditionalWithResource -> getResourceAmount(condition.params[0]) > 0
UniqueType.ConditionalWithoutResource -> getResourceAmount(condition.params[0]) <= 0
UniqueType.ConditionalWithResource -> state.getResourceAmount(condition.params[0]) > 0
UniqueType.ConditionalWithoutResource -> state.getResourceAmount(condition.params[0]) <= 0
UniqueType.ConditionalWhenAboveAmountStatResource ->
checkResourceOrStatAmount(condition.params[1], condition.params[0].toFloat(), Float.MAX_VALUE)
@ -233,15 +178,15 @@ object Conditionals {
checkOnGameInfo { getCities().any { it.cityConstructions.containsBuildingOrEquivalent(condition.params[0]) } }
// Filtered via city.getMatchingUniques
UniqueType.ConditionalInThisCity -> relevantCity != null
UniqueType.ConditionalCityFilter -> checkOnCity { matchesFilter(condition.params[0], relevantCiv) }
UniqueType.ConditionalInThisCity -> state.relevantCity != null
UniqueType.ConditionalCityFilter -> checkOnCity { matchesFilter(condition.params[0], state.relevantCiv) }
UniqueType.ConditionalCityConnected -> checkOnCity { isConnectedToCapital() }
UniqueType.ConditionalCityMajorReligion -> checkOnCity {
religion.getMajorityReligion()?.isMajorReligion() == true }
UniqueType.ConditionalCityEnhancedReligion -> checkOnCity {
religion.getMajorityReligion()?.isEnhancedReligion() == true }
UniqueType.ConditionalCityThisReligion -> checkOnCity {
religion.getMajorityReligion() == relevantCiv?.religionManager?.religion }
religion.getMajorityReligion() == state.relevantCiv?.religionManager?.religion }
UniqueType.ConditionalWLTKD -> checkOnCity { isWeLoveTheKingDayActive() }
UniqueType.ConditionalCityWithBuilding ->
checkOnCity { cityConstructions.containsBuildingOrEquivalent(condition.params[0]) }
@ -257,58 +202,58 @@ object Conditionals {
UniqueType.ConditionalVsCity -> state.theirCombatant?.matchesFilter("City") == true
UniqueType.ConditionalVsUnits, UniqueType.ConditionalVsCombatant -> state.theirCombatant?.matchesFilter(condition.params[0]) == true
UniqueType.ConditionalOurUnit, UniqueType.ConditionalOurUnitOnUnit ->
relevantUnit?.matchesFilter(condition.params[0]) == true
UniqueType.ConditionalUnitWithPromotion -> relevantUnit?.promotions?.promotions?.contains(condition.params[0]) == true
UniqueType.ConditionalUnitWithoutPromotion -> relevantUnit?.promotions?.promotions?.contains(condition.params[0]) == false
state.relevantUnit?.matchesFilter(condition.params[0]) == true
UniqueType.ConditionalUnitWithPromotion -> state.relevantUnit?.promotions?.promotions?.contains(condition.params[0]) == true
UniqueType.ConditionalUnitWithoutPromotion -> state.relevantUnit?.promotions?.promotions?.contains(condition.params[0]) == false
UniqueType.ConditionalAttacking -> state.combatAction == CombatAction.Attack
UniqueType.ConditionalDefending -> state.combatAction == CombatAction.Defend
UniqueType.ConditionalAboveHP -> relevantUnit != null && relevantUnit!!.health > condition.params[0].toInt()
UniqueType.ConditionalAboveHP -> state.relevantUnit != null && state.relevantUnit!!.health > condition.params[0].toInt()
|| state.ourCombatant != null && state.ourCombatant.getHealth() > condition.params[0].toInt()
UniqueType.ConditionalBelowHP -> relevantUnit != null && relevantUnit!!.health < condition.params[0].toInt()
UniqueType.ConditionalBelowHP -> state.relevantUnit != null && state.relevantUnit!!.health < condition.params[0].toInt()
||state.ourCombatant != null && state.ourCombatant.getHealth() < condition.params[0].toInt()
UniqueType.ConditionalHasNotUsedOtherActions ->
state.unit == null || // So we get the action as a valid action in BaseUnit.hasUnique()
state.unit.abilityToTimesUsed.isEmpty()
UniqueType.ConditionalInTiles ->
relevantTile?.matchesFilter(condition.params[0], relevantCiv) == true
state.relevantTile?.matchesFilter(condition.params[0], state.relevantCiv) == true
UniqueType.ConditionalInTilesNot ->
relevantTile?.matchesFilter(condition.params[0], relevantCiv) == false
UniqueType.ConditionalAdjacentTo -> relevantTile?.isAdjacentTo(condition.params[0], relevantCiv) == true
UniqueType.ConditionalNotAdjacentTo -> relevantTile?.isAdjacentTo(condition.params[0], relevantCiv) == false
state.relevantTile?.matchesFilter(condition.params[0], state.relevantCiv) == false
UniqueType.ConditionalAdjacentTo -> state.relevantTile?.isAdjacentTo(condition.params[0], state.relevantCiv) == true
UniqueType.ConditionalNotAdjacentTo -> state.relevantTile?.isAdjacentTo(condition.params[0], state.relevantCiv) == false
UniqueType.ConditionalFightingInTiles ->
state.attackedTile?.matchesFilter(condition.params[0], relevantCiv) == true
state.attackedTile?.matchesFilter(condition.params[0], state.relevantCiv) == true
UniqueType.ConditionalNearTiles ->
relevantTile != null && relevantTile!!.getTilesInDistance(condition.params[0].toInt()).any {
state.relevantTile != null && state.relevantTile!!.getTilesInDistance(condition.params[0].toInt()).any {
it.matchesFilter(condition.params[1])
}
UniqueType.ConditionalVsLargerCiv -> {
val yourCities = relevantCiv?.cities?.size ?: 1
val yourCities = state.relevantCiv?.cities?.size ?: 1
val theirCities = state.theirCombatant?.getCivInfo()?.cities?.size ?: 0
yourCities < theirCities
}
UniqueType.ConditionalForeignContinent -> checkOnCiv {
relevantTile != null && (
state.relevantTile != null && (
cities.isEmpty() || getCapital() == null
|| getCapital()!!.getCenterTile().getContinent() != relevantTile!!.getContinent()
|| getCapital()!!.getCenterTile().getContinent() != state.relevantTile!!.getContinent()
)
}
UniqueType.ConditionalAdjacentUnit ->
relevantCiv != null &&
relevantUnit != null &&
relevantTile!!.neighbors.any {
state.relevantCiv != null &&
state.relevantUnit != null &&
state.relevantTile!!.neighbors.any {
it.getUnits().any {
it != relevantUnit &&
it.civ == relevantCiv &&
it != state.relevantUnit &&
it.civ == state.relevantCiv &&
it.matchesFilter(condition.params[0])
}
}
UniqueType.ConditionalNeighborTiles ->
relevantTile != null
&& relevantTile!!.neighbors.count {
it.matchesFilter(condition.params[2], relevantCiv)
state.relevantTile != null
&& state.relevantTile!!.neighbors.count {
it.matchesFilter(condition.params[2], state.relevantCiv)
} in condition.params[0].toInt()..condition.params[1].toInt()
UniqueType.ConditionalOnWaterMaps -> state.region?.continentID == -1
@ -319,7 +264,7 @@ object Conditionals {
unique != null
&& unique.sourceObjectType == UniqueTarget.Tech
&& checkOnGameInfo { civilizations.none {
it != relevantCiv && it.isMajorCiv()
it != state.relevantCiv && it.isMajorCiv()
&& it.tech.isResearched(unique.sourceObjectName!!) // guarded by the sourceObjectType check
} }
@ -327,7 +272,7 @@ object Conditionals {
unique != null
&& unique.sourceObjectType == UniqueTarget.Policy
&& checkOnGameInfo { civilizations.none {
it != relevantCiv && it.isMajorCiv()
it != state.relevantCiv && it.isMajorCiv()
&& it.policies.isAdopted(unique.sourceObjectName!!) // guarded by the sourceObjectType check
} }

View File

@ -0,0 +1,30 @@
package com.unciv.models.ruleset.unique
import com.unciv.models.stats.Stat
object Countables {
fun getCountableAmount(countable: String, stateForConditionals: StateForConditionals): Int? {
if (countable.toIntOrNull() != null) return countable.toInt()
val relevantStat = Stat.safeValueOf(countable)
if (relevantStat != null) {
return if (stateForConditionals.relevantCity != null) {
stateForConditionals.relevantCity!!.getStatReserve(relevantStat)
} else if (relevantStat in Stat.statsWithCivWideField && stateForConditionals.relevantCiv != null) {
stateForConditionals.relevantCiv!!.getStatReserve(relevantStat)
} else {
null
}
}
if (stateForConditionals.gameInfo == null) return null
if (countable == "year") return stateForConditionals.gameInfo!!.getYear(stateForConditionals.gameInfo!!.turns)
if (stateForConditionals.gameInfo!!.ruleset.tileResources.containsKey(countable))
return stateForConditionals.getResourceAmount(countable)
return null
}
}

View File

@ -37,7 +37,10 @@ interface IHasUniques : INamed {
fun getMatchingUniques(uniqueTemplate: String, stateForConditionals: StateForConditionals? = null): Sequence<Unique> {
val matchingUniques = uniqueMap[uniqueTemplate] ?: return sequenceOf()
return matchingUniques.asSequence().filter { it.conditionalsApply(stateForConditionals ?: StateForConditionals()) }
val actualStateForConditionals = stateForConditionals ?: StateForConditionals()
val uniques = matchingUniques.asSequence().filter { it.conditionalsApply(actualStateForConditionals) }
return uniques.flatMap { it.getMultiplied(actualStateForConditionals) }
}
fun getMatchingUniques(uniqueType: UniqueType, stateForConditionals: StateForConditionals? = null) =

View File

@ -39,6 +39,38 @@ data class StateForConditionals(
combatAction
)
val relevantUnit by lazy {
if (ourCombatant != null && ourCombatant is MapUnitCombatant) ourCombatant.unit
else unit
}
val relevantTile by lazy { attackedTile
?: tile
// We need to protect against conditionals checking tiles for units pre-placement - see #10425, #10512
?: relevantUnit?.run { if (hasTile()) getTile() else null }
?: city?.getCenterTile()
}
val relevantCity by lazy {
city
?: relevantTile?.getCity()
}
val relevantCiv by lazy {
civInfo ?:
relevantCity?.civ ?:
relevantUnit?.civ
}
val gameInfo by lazy { relevantCiv?.gameInfo }
fun getResourceAmount(resourceName: String): Int {
if (relevantCity != null) return relevantCity!!.getAvailableResourceAmount(resourceName)
if (relevantCiv != null) return relevantCiv!!.getResourceAmount(resourceName)
return 0
}
companion object {
val IgnoreConditionals = StateForConditionals(ignoreConditionals = true)
}
@ -67,4 +99,7 @@ data class StateForConditionals(
result = 31 * result + ignoreConditionals.hashCode()
return result
}
}

View File

@ -68,6 +68,30 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s
return true
}
private fun getUniqueMultiplier(stateForConditionals: StateForConditionals = StateForConditionals()): Int {
val multiplierConditionals = conditionals.filter { it.type == UniqueType.ForEveryCountable }
if (multiplierConditionals.isEmpty()) return 1
var amount = 1
for (conditional in multiplierConditionals) { // multiple multipliers DO multiply.
val multiplier = Countables.getCountableAmount(conditional.params[0], stateForConditionals)
if (multiplier != null) amount *= multiplier
}
return amount.coerceAtLeast(0)
}
/** Multiplies the unique according to the multiplication conditionals */
fun getMultiplied(stateForConditionals: StateForConditionals = StateForConditionals()): Sequence<Unique> {
val multiplier = getUniqueMultiplier(stateForConditionals)
return EndlessSequenceOf(this).take(multiplier)
}
private class EndlessSequenceOf<T>(private val value: T) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
override fun next() = value
override fun hasNext() = true
}
}
fun getDeprecationAnnotation(): Deprecated? = type?.getDeprecationAnnotation()
fun getSourceNameForUser(): String {
@ -244,9 +268,6 @@ class UniqueMap() : HashMap<String, ArrayList<Unique>>() {
&& unique.conditionalsApply(stateForConditionals)
}
}
/** This is an alias for [containsKey] to clarify when a pure string-based check is legitimate. */
fun containsFilteringUnique(filter: String) = containsKey(filter)
}

View File

@ -87,7 +87,12 @@ object UniqueTriggerActivation {
val timingConditional = unique.conditionals.firstOrNull { it.type == UniqueType.ConditionalTimedUnique }
if (timingConditional != null) {
return { civInfo.temporaryUniques.add(TemporaryUnique(unique, timingConditional.params[0].toInt())) }
return {
civInfo.temporaryUniques.add(TemporaryUnique(unique, timingConditional.params[0].toInt()))
if (unique.type in setOf(UniqueType.ProvidesResources, UniqueType.ConsumesResources))
civInfo.cache.updateCivResources()
true
}
}
val stateForConditionals = StateForConditionals(civInfo, city, unit, tile)

View File

@ -870,6 +870,7 @@ enum class UniqueType(
HiddenWithoutVictoryType("Hidden when [victoryType] Victory is disabled", UniqueTarget.Building, UniqueTarget.Unit, flags = UniqueFlag.setOfHiddenToUsers),
HiddenFromCivilopedia("Will not be displayed in Civilopedia", *UniqueTarget.Displayable, flags = UniqueFlag.setOfHiddenToUsers),
ModifierHiddenFromUsers("hidden from users", UniqueTarget.MetaModifier),
ForEveryCountable("for every [countable]", UniqueTarget.MetaModifier),
Comment("Comment [comment]", *UniqueTarget.Displayable,
docDescription = "Allows displaying arbitrary text in a Unique listing. Only the text within the '[]' brackets will be displayed, the rest serves to allow Ruleset validation to recognize the intent."),

View File

@ -2479,6 +2479,11 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
??? example "&lt;hidden from users&gt;"
Applicable to: MetaModifier
??? example "&lt;for every [countable]&gt;"
Example: "&lt;for every [1000]&gt;"
Applicable to: MetaModifier
*[amount]: This indicates a whole number, possibly with a + or - sign, such as `2`, `+13`, or `-3`.
*[baseTerrain]: The name of any terrain that is a base terrain according to the json file.

View File

@ -1,5 +1,7 @@
package com.unciv.uniques
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.testing.GdxTestRunner
import com.unciv.testing.TestGame
import org.junit.Assert
@ -121,4 +123,16 @@ class ResourceTests {
city.cityConstructions.addBuilding(doubleStrategic)
Assert.assertTrue(civInfo.getCivResourcesByName()["Coal"] == 4)
}
@Test
fun testPerCountableForGlobalAndLocalResources() {
// one coal provided locally
val consumesCoal = game.createBuilding("Provides [1] [Coal]")
city.cityConstructions.addBuilding(consumesCoal)
// one globally
UniqueTriggerActivation.triggerUnique(Unique("Provides [1] [Coal] <for [2] turns>"), civInfo)
val providesFaithPerCoal = game.createBuilding("[+1 Faith] [in this city] <for every [Coal]>")
city.cityConstructions.addBuilding(providesFaithPerCoal)
Assert.assertEquals(2f, city.cityStats.currentCityStats.faith)
}
}