diff --git a/android/assets/jsons/Civ V - Gods & Kings/Buildings.json b/android/assets/jsons/Civ V - Gods & Kings/Buildings.json index 72743bf58b..0489aa7741 100644 --- a/android/assets/jsons/Civ V - Gods & Kings/Buildings.json +++ b/android/assets/jsons/Civ V - Gods & Kings/Buildings.json @@ -417,7 +417,7 @@ "cost": 125, "culture": 1, "isNationalWonder": true, - "uniques": ["Requires a [Barracks] in all cities", "All newly-trained [non-air] units [in this city] receive the [Morale] promotion", + "uniques": ["Requires a [Barracks] in all cities", "All newly-trained [non-[Air]] units [in this city] receive the [Morale] promotion", "Cost increases by [30] per owned city"], "requiredTech": "Iron Working" }, diff --git a/android/assets/jsons/Civ V - Gods & Kings/Nations.json b/android/assets/jsons/Civ V - Gods & Kings/Nations.json index 768d054bde..1141815353 100644 --- a/android/assets/jsons/Civ V - Gods & Kings/Nations.json +++ b/android/assets/jsons/Civ V - Gods & Kings/Nations.json @@ -329,7 +329,7 @@ "innerColor": [184, 0, 0], "favoredReligion": "Shinto", "uniqueName": "Bushido", - "uniques": ["Damage is ignored when determining unit Strength "], + "uniques": ["Damage is ignored when determining unit Strength "], "cities": ["Kyoto","Osaka","Tokyo","Satsuma","Kagoshima","Nara","Nagoya","Izumo","Nagasaki","Yokohama", "Shimonoseki","Matsuyama","Sapporo","Hakodate","Ise","Toyama","Fukushima","Suo","Bizen","Echizen", "Izumi","Omi","Echigo","Kozuke","Sado","Kobe","Nagano","Hiroshima","Takayama","Akita","Fukuoka","Aomori", diff --git a/android/assets/jsons/Civ V - Vanilla/Buildings.json b/android/assets/jsons/Civ V - Vanilla/Buildings.json index be31c72cc9..3afaee552c 100644 --- a/android/assets/jsons/Civ V - Vanilla/Buildings.json +++ b/android/assets/jsons/Civ V - Vanilla/Buildings.json @@ -360,7 +360,7 @@ "cost": 125, "culture": 1, "isNationalWonder": true, - "uniques": ["Requires a [Barracks] in all cities", "All newly-trained [non-air] units [in this city] receive the [Morale] promotion", + "uniques": ["Requires a [Barracks] in all cities", "All newly-trained [non-[Air]] units [in this city] receive the [Morale] promotion", "Cost increases by [30] per owned city"], "requiredTech": "Iron Working" }, diff --git a/android/assets/jsons/Civ V - Vanilla/Nations.json b/android/assets/jsons/Civ V - Vanilla/Nations.json index 2aff2e2a45..5de0293f83 100644 --- a/android/assets/jsons/Civ V - Vanilla/Nations.json +++ b/android/assets/jsons/Civ V - Vanilla/Nations.json @@ -304,7 +304,7 @@ "outerColor": [255, 255, 255], "innerColor": [184, 0, 0], "uniqueName": "Bushido", - "uniques": ["Damage is ignored when determining unit Strength "], + "uniques": ["Damage is ignored when determining unit Strength "], "cities": ["Kyoto","Osaka","Tokyo","Satsuma","Kagoshima","Nara","Nagoya","Izumo","Nagasaki","Yokohama", "Shimonoseki","Matsuyama","Sapporo","Hakodate","Ise","Toyama","Fukushima","Suo","Bizen","Echizen", "Izumi","Omi","Echigo","Kozuke","Sado","Kobe","Nagano","Hiroshima","Takayama","Akita","Fukuoka","Aomori", diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index c8985ead09..94cae0802a 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -1550,6 +1550,7 @@ Resource [resource] does not exist in ruleset! = Improvement [improvement] does not exist in ruleset! = Nation [nation] does not exist in ruleset! = Natural Wonder [naturalWonder] does not exist in ruleset! = +non-[filter] = # Civilopedia difficulty levels Player settings = diff --git a/core/src/com/unciv/logic/MultiFilter.kt b/core/src/com/unciv/logic/MultiFilter.kt new file mode 100644 index 0000000000..84564c2996 --- /dev/null +++ b/core/src/com/unciv/logic/MultiFilter.kt @@ -0,0 +1,17 @@ +package com.unciv.logic + +object MultiFilter { + fun multiFilter(input: String, filterFunction: (String)->Boolean, + /** Unique validity doesn't check for actual matching */ forUniqueValidityTests:Boolean=false): Boolean { + if (input.contains("} {")) + return input.removePrefix("{").removeSuffix("}").split("} {") + .all{ multiFilter(it, filterFunction) } + if (input.startsWith("non-[") && input.endsWith("]")) { + val internalResult = multiFilter(input.removePrefix("non-[").removeSuffix("]"), filterFunction) + return if (forUniqueValidityTests) internalResult else !internalResult + } + return filterFunction(input) + } + + +} diff --git a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt index 8cdfaca59c..ed9fa07ee0 100644 --- a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt +++ b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt @@ -3,6 +3,7 @@ package com.unciv.logic.map.mapunit import com.badlogic.gdx.math.Vector2 import com.unciv.Constants import com.unciv.logic.IsPartOfGameInfoSerialization +import com.unciv.logic.MultiFilter import com.unciv.logic.automation.unit.UnitAutomation import com.unciv.logic.battle.BattleUnitCapture import com.unciv.logic.battle.MapUnitCombatant @@ -24,7 +25,6 @@ import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.ruleset.unit.UnitType import com.unciv.models.stats.Stats import com.unciv.ui.components.UnitMovementMemoryType -import com.unciv.ui.components.extensions.filterAndLogic import java.text.DecimalFormat import kotlin.math.pow import kotlin.math.ulp @@ -888,9 +888,11 @@ class MapUnit : IsPartOfGameInfoSerialization { /** Implements [UniqueParameterType.MapUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.MapUnitFilter] */ fun matchesFilter(filter: String): Boolean { - return filter.filterAndLogic { matchesFilter(it) } // multiple types at once - AND logic. Looks like:"{Military} {Land}" - ?: when (filter) { + return MultiFilter.multiFilter(filter, ::matchesSingleFilter) + } + private fun matchesSingleFilter(filter:String): Boolean { + return when (filter) { Constants.wounded, "wounded units" -> health < 100 Constants.barbarians, "Barbarian" -> civ.isBarbarian() "City-State" -> civ.isCityState() diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt index 02d708f352..6118444bc6 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt @@ -1,6 +1,7 @@ package com.unciv.models.ruleset.unique import com.unciv.Constants +import com.unciv.logic.MultiFilter import com.unciv.models.metadata.BaseRuleset import com.unciv.models.ruleset.BeliefType import com.unciv.models.ruleset.Ruleset @@ -9,7 +10,6 @@ import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.unique.UniqueParameterType.Companion.guessTypeForTranslationWriter import com.unciv.models.stats.Stat import com.unciv.models.translations.TranslationFileWriter -import com.unciv.ui.components.extensions.filterCompositeLogic // 'region' names beginning with an underscore are used here for a prettier "Structure window" - they go in front ot the rest. @@ -39,6 +39,8 @@ enum class UniqueParameterType( ) { //endregion + + Number("amount", "3", "This indicates a whole number, possibly with a + or - sign, such as `2`, `+13`, or `-3`") { override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? { @@ -72,61 +74,76 @@ enum class UniqueParameterType( CombatantFilter("combatantFilter", "City", "This indicates a combatant, which can either be a unit or a city (when bombarding). Must either be `City` or a `mapUnitFilter`") { override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? { - if (parameterText == "City") return null // City also recognizes "All" but that's covered by UnitTypeFilter too + if (parameterText == "City") return null // City also recognizes "All" but that's covered by BaseUnitFilter too return MapUnitFilter.getErrorSeverity(parameterText, ruleset) } }, /** Implemented by [MapUnit.matchesFilter][com.unciv.logic.map.mapunit.MapUnit.matchesFilter] */ MapUnitFilter("mapUnitFilter", Constants.wounded, null, "Map Unit Filters") { - private val knownValues = setOf(Constants.wounded, Constants.barbarians, "City-State", Constants.embarked, "Non-City") + private val knownValues = setOf(Constants.wounded, Constants.barbarians, "Barbarian", + "City-State", Constants.embarked, "Non-City") + override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? { - if (parameterText.startsWith('{')) // "{filter} {filter}" for and logic - return parameterText.filterCompositeLogic({ getErrorSeverity(it, ruleset) }) { a, b -> maxOf(a, b) } - if (parameterText in knownValues) return null - if (ruleset.unitPromotions.values.any { it.hasUnique(parameterText) }) - return null - return BaseUnitFilter.getErrorSeverity(parameterText, ruleset) + val isKnown = MultiFilter.multiFilter(parameterText, {isKnownValue(it, ruleset)}, true) + if (isKnown) return null + return UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique } + + override fun isKnownValue(parameterText:String, ruleset: Ruleset): Boolean { + if (parameterText in knownValues) return true + if (ruleset.unitPromotions.values.any { it.hasUnique(parameterText) }) return true + if (BaseUnitFilter.isKnownValue(parameterText, ruleset)) return true + return false + } + override fun getTranslationWriterStringsForOutput() = knownValues }, /** Implemented by [BaseUnit.matchesFilter][com.unciv.models.ruleset.unit.BaseUnit.matchesFilter] */ BaseUnitFilter("baseUnitFilter", "Melee") { + private val knownValues = setOf( + "All", "Melee", "Ranged", "Civilian", "Military", "non-air", + "Nuclear Weapon", "Great Person", "Religious", + "relevant", // used for UniqueType.UnitStartingPromotions + ) override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? { - if (parameterText.startsWith('{')) // "{filter} {filter}" for and logic - return parameterText.filterCompositeLogic({ getErrorSeverity(it, ruleset) }) { a, b -> maxOf(a, b) } - if (UnitName.getErrorSeverity(parameterText, ruleset) == null) return null - if (ruleset.units.values.any { it.uniques.contains(parameterText) }) return null - return UnitTypeFilter.getErrorSeverity(parameterText, ruleset) + val isKnown = MultiFilter.multiFilter(parameterText, {isKnownValue(it, ruleset)}, true) + if (isKnown) return null + return UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique + } + override fun isKnownValue(parameterText:String, ruleset: Ruleset): Boolean { + if (parameterText in knownValues) return true + if (UnitName.getErrorSeverity(parameterText, ruleset) == null) return true + if (ruleset.units.values.any { it.uniques.contains(parameterText) }) return true + if (UnitTypeFilter.isKnownValue(parameterText, ruleset)) return true + return false } }, /** Implemented by [UnitType.matchesFilter][com.unciv.models.ruleset.unit.UnitType.matchesFilter] */ - //todo there is a large discrepancy between this parameter type and the actual filter, most of these are actually implemented by BaseUnitFilter UnitTypeFilter("unitType", "Water", null, "Unit Type Filters") { - // As you can see there is a difference between these and what's in unitTypeStrings (for translation) - - // the goal is to unify, but for now this is the "real" list - // Note: this can't handle combinations of parameters (e.g. [{Military} {Water}]) private val knownValues = setOf( - "All", "Melee", "Ranged", "Civilian", "Military", "Land", "Water", "Air", - "non-air", "Nuclear Weapon", "Great Person", "Religious", "Barbarian", - "relevant", "City", - // These are up for debate -// "land units", "water units", "air units", "military units", "submarine units", + "Land", "Water", "Air", ) override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? { - if (parameterText in knownValues) return null - if (ruleset.unitTypes.containsKey(parameterText)) return null - if (ruleset.eras.containsKey(parameterText)) return null - if (ruleset.unitTypes.values.any { it.uniques.contains(parameterText) }) return null + val isKnown = MultiFilter.multiFilter(parameterText, {isKnownValue(it, ruleset)}, true) + if (isKnown) return null return UniqueType.UniqueParameterErrorSeverity.PossibleFilteringUnique } + override fun isKnownValue(parameterText: String, ruleset: Ruleset): Boolean { + if (parameterText in knownValues) return true + if (ruleset.unitTypes.containsKey(parameterText)) return true + if (ruleset.eras.containsKey(parameterText)) return true + if (ruleset.unitTypes.values.any { it.uniques.contains(parameterText) }) return true + return false + } + override fun isTranslationWriterGuess(parameterText: String, ruleset: Ruleset) = parameterText in ruleset.unitTypes.keys || parameterText in getTranslationWriterStringsForOutput() @@ -560,6 +577,8 @@ enum class UniqueParameterType( /** Validate a [Unique] parameter */ abstract fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? + open fun isKnownValue(parameterText: String, ruleset: Ruleset): Boolean = false + /** Pick this type when [TranslationFileWriter] tries to guess for an untyped [Unique] */ open fun isTranslationWriterGuess(parameterText: String, ruleset: Ruleset): Boolean = getErrorSeverity(parameterText, ruleset) == null diff --git a/core/src/com/unciv/models/ruleset/unit/BaseUnit.kt b/core/src/com/unciv/models/ruleset/unit/BaseUnit.kt index 7c932ffbdd..6362dec7a3 100644 --- a/core/src/com/unciv/models/ruleset/unit/BaseUnit.kt +++ b/core/src/com/unciv/models/ruleset/unit/BaseUnit.kt @@ -1,5 +1,6 @@ package com.unciv.models.ruleset.unit +import com.unciv.logic.MultiFilter import com.unciv.logic.city.City import com.unciv.logic.city.CityConstructions import com.unciv.logic.civilization.Civilization @@ -15,7 +16,6 @@ import com.unciv.models.ruleset.unique.Unique import com.unciv.models.ruleset.unique.UniqueTarget import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.stats.Stat -import com.unciv.ui.components.extensions.filterAndLogic import com.unciv.ui.components.extensions.getNeedMoreAmountString import com.unciv.ui.components.extensions.toPercent import com.unciv.ui.objectdescriptions.BaseUnitDescriptions @@ -264,9 +264,11 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction { /** Implements [UniqueParameterType.BaseUnitFilter][com.unciv.models.ruleset.unique.UniqueParameterType.BaseUnitFilter] */ fun matchesFilter(filter: String): Boolean { - return filter.filterAndLogic { matchesFilter(it) } // multiple types at once - AND logic. Looks like:"{Military} {Land}" - ?: when (filter) { + return MultiFilter.multiFilter(filter, ::matchesSingleFilter) + } + fun matchesSingleFilter(filter: String): Boolean { + return when (filter) { unitType -> true name -> true replaces -> true @@ -287,9 +289,9 @@ class BaseUnit : RulesetObject(), INonPerpetualConstruction { else -> { if (type.matchesFilter(filter)) return true - if (requiredTech != null && ruleset.technologies[requiredTech]?.matchesFilter(filter)==true) return true + if (requiredTech != null && ruleset.technologies[requiredTech]?.matchesFilter(filter) == true) return true if ( - // Uniques using these kinds of filters should be deprecated and replaced with adjective-only parameters + // Uniques using these kinds of filters should be deprecated and replaced with adjective-only parameters filter.endsWith(" units") // "military units" --> "Military", using invariant locale && matchesFilter(filter.removeSuffix(" units").lowercase().replaceFirstChar { it.uppercaseChar() }) diff --git a/docs/Modders/Unique-parameters.md b/docs/Modders/Unique-parameters.md index ac42f8bc61..14213ffaf1 100644 --- a/docs/Modders/Unique-parameters.md +++ b/docs/Modders/Unique-parameters.md @@ -9,6 +9,18 @@ These are split into two categories: Note that all of these are case-sensitive! +## General Filter Rules + +- All filters accept multiple values in the format: `{A} {B} {C}` etc, meaning "the object must match ALL of these filters" + - For example: `[{Military} {Water}] units`, `[{Wounded} {Armor}] units`, etc. + - No space or other text is allowed between the `[` and the first `{`. +- All filters accept `non-[filter]` as a possible value + - For example: `[non-[Wounded]] units` +- These can be combined by having the values be negative filters + - For example: `[{non-[Wounded]} {Armor}] units` +- These CANNOT be combined in the other way - e.g. `[non-[{Wounded} {Armor}]] units` is NOT valid and will fail to register any units. + - This is because to the player, the text will be `non-Wounded Armor units`, which parses like `[{non-[Wounded]} {Armor}] units` + ## civFilter Allows filtering for specific civs. @@ -46,7 +58,7 @@ The following are allowed to be used: - Matching [technologyfilter](#technologyfilter) for the tech this unit requires - e.g. `Modern Era` - Any exact unique the unit has - Any exact unique the unit type has -- Any combination of the above (will match only if all match). The format is `{filter1} {filter2}` and can match any number of filters. For example: `[{Military} {Water}]` units, `[{non-air} {Armor}]` units, etc. No space or other text is allowed between the `[` and the first `{`. +- Any combination of the above (will match only if all match). The format is `{filter1} {filter2}` and can match any number of filters. For example: ` ## mapUnitFilter diff --git a/docs/Modders/uniques.md b/docs/Modders/uniques.md index 62e6e83a78..4e76fd8919 100644 --- a/docs/Modders/uniques.md +++ b/docs/Modders/uniques.md @@ -785,6 +785,11 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl Applicable to: Global, Unit +??? example "[relativeAmount] Air Interception Range" + Example: "[+20] Air Interception Range" + + Applicable to: Global, Unit + ??? example "[amount] HP when healing" Example: "[3] HP when healing" diff --git a/tests/src/com/unciv/logic/MultiFilterTests.kt b/tests/src/com/unciv/logic/MultiFilterTests.kt new file mode 100644 index 0000000000..c079c10953 --- /dev/null +++ b/tests/src/com/unciv/logic/MultiFilterTests.kt @@ -0,0 +1,28 @@ +package com.unciv.logic + +import com.unciv.testing.GdxTestRunner +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(GdxTestRunner::class) +class MultiFilterTests { + @Test + fun testSplitTerms() { + Assert.assertTrue(MultiFilter.multiFilter("{A} {B}", { it=="A" || it=="B"})) + Assert.assertFalse(MultiFilter.multiFilter("{A} {B}", { it=="A"})) + Assert.assertFalse(MultiFilter.multiFilter("{A} {B}", { it=="B"})) + } + + @Test + fun testNotTerm() { + Assert.assertTrue(MultiFilter.multiFilter("non-[B]", { it=="A"})) + Assert.assertFalse(MultiFilter.multiFilter("non-[A]", { it=="A"})) + } + + @Test + fun testSplitNotTerm() { + Assert.assertTrue(MultiFilter.multiFilter("{non-[A]} {non-[B]}", { it=="C"})) + Assert.assertFalse(MultiFilter.multiFilter("{non-[A]} {non-[B]}", { it=="A"})) + } +}