From f529d969f08e36805fa22187c7013b44afeae416 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Fri, 22 Dec 2023 08:59:38 +0100 Subject: [PATCH] Mod declarative compatibility - a little more (#10751) * Mod compatibility - update declarations * Mod compatibility - logic and UI changes * Mod compatibility - flag some invalid use patterns * RulesetValidator - lint until Studio shuts up * Fix isBaseRuleset test in ModRequires validation --------- Co-authored-by: Yair Morgenstern --- android/assets/Skin.json | 10 ++ .../ruleset/unique/UniqueParameterType.kt | 2 +- .../unciv/models/ruleset/unique/UniqueType.kt | 10 +- .../ruleset/validation/ModCompatibility.kt | 119 ++++++++++++++++++ .../ruleset/validation/RulesetValidator.kt | 29 ++++- .../modmanager/ModInfoAndActionPane.kt | 27 +--- .../screens/newgamescreen/ModCheckboxTable.kt | 57 ++++++--- docs/Modders/uniques.md | 7 ++ 8 files changed, 211 insertions(+), 50 deletions(-) create mode 100644 core/src/com/unciv/models/ruleset/validation/ModCompatibility.kt diff --git a/android/assets/Skin.json b/android/assets/Skin.json index d98e334f5b..07bafa561b 100644 --- a/android/assets/Skin.json +++ b/android/assets/Skin.json @@ -179,6 +179,14 @@ "name": "Checkbox-pressed", "color": "color" }, + "checkbox-disabled-c": { + "name": "Checkbox", + "color": "disabled" + }, + "checkbox-pressed-disabled-c": { + "name": "Checkbox-pressed", + "color": "disabled" + }, "list-c": { "name": "RectangleWithOutline", "color": "color" @@ -258,7 +266,9 @@ "com.badlogic.gdx.scenes.scene2d.ui.CheckBox$CheckBoxStyle": { "default": { "checkboxOn": "checkbox-pressed-c", + "checkboxOnDisabled": "checkbox-pressed-disabled-c", "checkboxOff": "checkbox-c", + "checkboxOffDisabled": "checkbox-disabled-c", "font": "button", "fontColor": "color", "downFontColor": "pressed", diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt index 9a72d75d50..b390d7c75f 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt @@ -572,7 +572,7 @@ enum class UniqueParameterType( override fun getTranslationWriterStringsForOutput() = knownValues }, - /** Mod declarative compatibility: Behaves like [Unknown], but makes for nicer auto-generated documentation. */ + /** Mod declarative compatibility: Define Mod relations by their name. */ ModName("modFilter", "DeCiv Redux", """A Mod name, case-sensitive _or_ a simple wildcard filter beginning and ending in an Asterisk, case-insensitive""", "Mod name filter") { override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueParameterErrorSeverity? = diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index 54f9f9d40c..e5a37dff34 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -795,8 +795,14 @@ enum class UniqueType( 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."), - // Declarative Mod compatibility (so far rudimentary): - ModIncompatibleWith("Mod is incompatible with [modFilter]", UniqueTarget.ModOptions), + // Declarative Mod compatibility (see [ModCompatibility]): + // Note there is currently no display for these, but UniqueFlag.HiddenToUsers is not set. + // That means we auto-template and ask our translators for a translation that is currently unused. + //todo To think over - leave as is for future use or remove templates and translations by adding the flag? + ModIncompatibleWith("Mod is incompatible with [modFilter]", UniqueTarget.ModOptions, + docDescription = "Specifies that your Mod is incompatible with another. Always treated symmetrically, and cannot be overridden by the Mod you are declaring as incompatible."), + ModRequires("Mod requires [modFilter]", UniqueTarget.ModOptions, + docDescription = "Specifies that your Extension Mod is only available if any other Mod matching the filter is active."), ModIsAudioVisualOnly("Should only be used as permanent audiovisual mod", UniqueTarget.ModOptions), ModIsAudioVisual("Can be used as permanent audiovisual mod", UniqueTarget.ModOptions), ModIsNotAudioVisual("Cannot be used as permanent audiovisual mod", UniqueTarget.ModOptions), diff --git a/core/src/com/unciv/models/ruleset/validation/ModCompatibility.kt b/core/src/com/unciv/models/ruleset/validation/ModCompatibility.kt new file mode 100644 index 0000000000..738bc7d898 --- /dev/null +++ b/core/src/com/unciv/models/ruleset/validation/ModCompatibility.kt @@ -0,0 +1,119 @@ +package com.unciv.models.ruleset.validation + +import com.badlogic.gdx.files.FileHandle +import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.models.ruleset.validation.ModCompatibility.meetsAllRequirements +import com.unciv.models.ruleset.validation.ModCompatibility.meetsBaseRequirements + +/** + * Helper collection dealing with declarative Mod compatibility + * + * Implements: + * - [UniqueType.ModRequires] + * - [UniqueType.ModIncompatibleWith] + * - [UniqueType.ModIsAudioVisual] + * - [UniqueType.ModIsNotAudioVisual] + * - [UniqueType.ModIsAudioVisualOnly] + * + * Methods: + * - [meetsBaseRequirements] - to build a checkbox list of Extension mods + * - [meetsAllRequirements] - to see if a mod is allowed in the context of a complete mod selection + */ +object ModCompatibility { + /** + * Should the "Permanent Audiovisual Mod" checkbox be shown for [mod]? + * + * Note: The guessing part may potentially be deprecated and removed if we get our Modders to complete declarative coverage. + */ + fun isAudioVisualMod(mod: Ruleset) = isAudioVisualDeclared(mod) ?: isAudioVisualGuessed(mod) + + private fun isAudioVisualDeclared(mod: Ruleset): Boolean? { + if (mod.modOptions.hasUnique(UniqueType.ModIsAudioVisualOnly)) return true + if (mod.modOptions.hasUnique(UniqueType.ModIsAudioVisual)) return true + if (mod.modOptions.hasUnique(UniqueType.ModIsNotAudioVisual)) return false + return null + } + + // If there's media (audio folders or any atlas), show the PAV choice... + private fun isAudioVisualGuessed(mod: Ruleset): Boolean { + val folder = mod.folderLocation ?: return false // Also catches isBuiltin + fun isSubFolderNotEmpty(modFolder: FileHandle, name: String): Boolean { + val file = modFolder.child(name) + if (!file.exists()) return false + if (!file.isDirectory) return false + return file.list().isNotEmpty() + } + if (isSubFolderNotEmpty(folder, "music")) return true + if (isSubFolderNotEmpty(folder, "sounds")) return true + if (isSubFolderNotEmpty(folder, "voices")) return true + return folder.list("atlas").isNotEmpty() + } + + fun isExtensionMod(mod: Ruleset) = + !mod.modOptions.isBaseRuleset + && mod.name.isNotBlank() + && !mod.modOptions.hasUnique(UniqueType.ModIsAudioVisualOnly) + + private fun modNameFilter(modName: String, filter: String): Boolean { + if (modName == filter) return true + if (filter.length < 3 || !filter.startsWith('*') || !filter.endsWith('*')) return false + val partialName = filter.substring(1, filter.length - 1).lowercase() + return partialName in modName.lowercase() + } + + private fun isIncompatibleWith(mod: Ruleset, otherMod: Ruleset) = + mod.modOptions.getMatchingUniques(UniqueType.ModIncompatibleWith) + .any { modNameFilter(otherMod.name, it.params[0]) } + + private fun isIncompatible(mod: Ruleset, otherMod: Ruleset) = + isIncompatibleWith(mod, otherMod) || isIncompatibleWith(otherMod, mod) + + /** Implement [UniqueType.ModRequires] and [UniqueType.ModIncompatibleWith] + * for selecting extension mods to show - after a [baseRuleset] was chosen. + * + * - Extension mod is incompatible with [baseRuleset] -> Nope + * - Extension mod has no ModRequires unique -> OK + * - For each ModRequires: Not ([baseRuleset] meets filter OR any other cached _extension_ mod meets filter) -> Nope + * - All ModRequires tested -> OK + */ + fun meetsBaseRequirements(mod: Ruleset, baseRuleset: Ruleset): Boolean { + if (isIncompatible(mod, baseRuleset)) return false + + val allOtherExtensionModNames = RulesetCache.values.asSequence() + .filter { it != mod && !it.modOptions.isBaseRuleset && it.name.isNotEmpty() } + .map { it.name } + .toList() + + for (unique in mod.modOptions.getMatchingUniques(UniqueType.ModRequires)) { + val filter = unique.params[0] + if (modNameFilter(baseRuleset.name, filter)) continue + if (allOtherExtensionModNames.none { modNameFilter(it, filter) }) return false + } + return true + } + + /** Implement [UniqueType.ModRequires] and [UniqueType.ModIncompatibleWith] + * for _enabling_ shown extension mods depending on other extension choices + * + * @param selectedExtensionMods all "active" mods for the compatibility tests - including the testee [mod] itself in this is allowed, it will be ignored. Will be iterated only once. + * + * - No need to test: Extension mod is incompatible with [baseRuleset] - we expect [meetsBaseRequirements] did exclude it from the UI entirely + * - Extension mod is incompatible with any _other_ **selected** extension mod -> Nope + * - Extension mod has no ModRequires unique -> OK + * - For each ModRequires: Not([baseRuleset] meets filter OR any other **selected** extension mod meets filter) -> Nope + * - All ModRequires tested -> OK + */ + fun meetsAllRequirements(mod: Ruleset, baseRuleset: Ruleset, selectedExtensionMods: Iterable): Boolean { + val otherSelectedExtensionMods = selectedExtensionMods.filterNot { it == mod }.toList() + if (otherSelectedExtensionMods.any { isIncompatible(mod, it) }) return false + + for (unique in mod.modOptions.getMatchingUniques(UniqueType.ModRequires)) { + val filter = unique.params[0] + if (modNameFilter(baseRuleset.name, filter)) continue + if (otherSelectedExtensionMods.none { modNameFilter(it.name, filter) }) return false + } + return true + } +} diff --git a/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt b/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt index 2914883bf1..7ad3746e68 100644 --- a/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt +++ b/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt @@ -39,6 +39,7 @@ class RulesetValidator(val ruleset: Ruleset) { val lines = RulesetErrorList() // When not checking the entire ruleset, we can only really detect ruleset-invariant errors in uniques + addModOptionsErrors(lines) uniqueValidator.checkUniques(ruleset.globalUniques, lines, false, tryFixUnknownUniques) addUnitErrorsRulesetInvariant(lines, tryFixUnknownUniques) addTechErrorsRulesetInvariant(lines, tryFixUnknownUniques) @@ -57,11 +58,12 @@ class RulesetValidator(val ruleset: Ruleset) { } - private fun getBaseRulesetErrorList(tryFixUnknownUniques: Boolean): RulesetErrorList{ + private fun getBaseRulesetErrorList(tryFixUnknownUniques: Boolean): RulesetErrorList { uniqueValidator.populateFilteringUniqueHashsets() val lines = RulesetErrorList() + addModOptionsErrors(lines) uniqueValidator.checkUniques(ruleset.globalUniques, lines, true, tryFixUnknownUniques) addUnitErrorsBaseRuleset(lines, tryFixUnknownUniques) @@ -81,7 +83,7 @@ class RulesetValidator(val ruleset: Ruleset) { addPromotionErrors(lines, tryFixUnknownUniques) addUnitTypeErrors(lines, tryFixUnknownUniques) addVictoryTypeErrors(lines) - addDifficutlyErrors(lines) + addDifficultyErrors(lines) addCityStateTypeErrors(tryFixUnknownUniques, lines) // Check for mod or Civ_V_GnK to avoid running the same test twice (~200ms for the builtin assets) @@ -92,6 +94,22 @@ class RulesetValidator(val ruleset: Ruleset) { return lines } + private fun addModOptionsErrors(lines: RulesetErrorList) { + if (ruleset.name.isBlank()) return // These tests don't make sense for combined rulesets + + val audioVisualUniqueTypes = setOf( + UniqueType.ModIsAudioVisual, + UniqueType.ModIsAudioVisualOnly, + UniqueType.ModIsNotAudioVisual + ) + if (ruleset.modOptions.uniqueObjects.count { it.type in audioVisualUniqueTypes } > 1) + lines += "A mod should only specify one of the 'can/should/cannot be used as permanent audiovisual mod' options." + if (!ruleset.modOptions.isBaseRuleset) return + for (unique in ruleset.modOptions.getMatchingUniques(UniqueType.ModRequires)) { + lines += "Mod option '${unique.text}' is invalid for a base ruleset." + } + } + private fun addCityStateTypeErrors( tryFixUnknownUniques: Boolean, lines: RulesetErrorList @@ -109,7 +127,7 @@ class RulesetValidator(val ruleset: Ruleset) { } } - private fun addDifficutlyErrors(lines: RulesetErrorList) { + private fun addDifficultyErrors(lines: RulesetErrorList) { for (difficulty in ruleset.difficulties.values) { for (unitName in difficulty.aiCityStateBonusStartingUnits + difficulty.aiMajorCivBonusStartingUnits + difficulty.playerBonusStartingUnits) if (unitName != Constants.eraSpecificUnit && !ruleset.units.containsKey(unitName)) @@ -179,6 +197,7 @@ class RulesetValidator(val ruleset: Ruleset) { tryFixUnknownUniques: Boolean ) { for (reward in ruleset.ruinRewards.values) { + @Suppress("KotlinConstantConditions") // data is read from json, so any assumptions may be wrong if (reward.weight < 0) lines += "${reward.name} has a negative weight, which is not allowed!" for (difficulty in reward.excludedDifficulties) if (!ruleset.difficulties.containsKey(difficulty)) @@ -439,7 +458,7 @@ class RulesetValidator(val ruleset: Ruleset) { for (requiredTech: String in building.requiredTechs()) if (!ruleset.technologies.containsKey(requiredTech)) - lines += "${building.name} requires tech ${requiredTech} which does not exist!" + lines += "${building.name} requires tech $requiredTech which does not exist!" for (specialistName in building.specialistSlots.keys) if (!ruleset.specialists.containsKey(specialistName)) lines += "${building.name} provides specialist $specialistName which does not exist!" @@ -660,7 +679,7 @@ class RulesetValidator(val ruleset: Ruleset) { private fun checkUnitRulesetSpecific(unit: BaseUnit, lines: RulesetErrorList) { for (requiredTech: String in unit.requiredTechs()) if (!ruleset.technologies.containsKey(requiredTech)) - lines += "${unit.name} requires tech ${requiredTech} which does not exist!" + lines += "${unit.name} requires tech $requiredTech which does not exist!" for (obsoleteTech: String in unit.techsAtWhichNoLongerAvailable()) if (!ruleset.technologies.containsKey(obsoleteTech)) lines += "${unit.name} obsoletes at tech ${obsoleteTech} which does not exist!" diff --git a/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt index 5d18143540..24e22c00c8 100644 --- a/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt +++ b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt @@ -1,13 +1,12 @@ package com.unciv.ui.screens.modmanager import com.badlogic.gdx.Gdx -import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.models.metadata.BaseRuleset import com.unciv.models.ruleset.Ruleset -import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.models.ruleset.validation.ModCompatibility import com.unciv.models.translations.tr import com.unciv.ui.components.extensions.UncivDateFormat.formatDate import com.unciv.ui.components.extensions.UncivDateFormat.parseDate @@ -52,7 +51,7 @@ internal class ModInfoAndActionPane : Table() { val modName = mod.name val modOptions = mod.modOptions // The ModOptions as enriched by us with GitHub metadata when originally downloaded isBuiltin = modOptions.modUrl.isEmpty() && BaseRuleset.values().any { it.fullName == modName } - enableVisualCheckBox = shouldShowVisualCheckbox(mod) + enableVisualCheckBox = ModCompatibility.isAudioVisualMod(mod) update( modName, modOptions.modUrl, modOptions.defaultBranch, modOptions.lastUpdated, modOptions.author, modOptions.modSize @@ -170,26 +169,4 @@ internal class ModInfoAndActionPane : Table() { cell.size(texture.width * resizeRatio, texture.height * resizeRatio) } } - - private fun shouldShowVisualCheckbox(mod: Ruleset): Boolean { - val folder = mod.folderLocation ?: return false // Also catches isBuiltin - - // Check declared Mod Compatibility - if (mod.modOptions.hasUnique(UniqueType.ModIsAudioVisualOnly)) return true - if (mod.modOptions.hasUnique(UniqueType.ModIsAudioVisual)) return true - if (mod.modOptions.hasUnique(UniqueType.ModIsNotAudioVisual)) return false - - // The following is the "guessing" part: If there's media, show the PAV choice... - // Might be deprecated if declarative Mod compatibility succeeds - fun isSubFolderNotEmpty(modFolder: FileHandle, name: String): Boolean { - val file = modFolder.child(name) - if (!file.exists()) return false - if (!file.isDirectory) return false - return file.list().isNotEmpty() - } - if (isSubFolderNotEmpty(folder, "music")) return true - if (isSubFolderNotEmpty(folder, "sounds")) return true - if (isSubFolderNotEmpty(folder, "voices")) return true - return folder.list("atlas").isNotEmpty() - } } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt index 82b5b19552..0409c2b502 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt @@ -6,7 +6,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache -import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.models.ruleset.validation.ModCompatibility import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.toCheckBox import com.unciv.ui.components.input.onChange @@ -49,10 +49,8 @@ class ModCheckboxTable( private val expanderPadTop = if (isPortrait) 0f else 16f init { - val modRulesets = RulesetCache.values.filterNot { - it.modOptions.isBaseRuleset - || it.name.isBlank() - || it.modOptions.hasUnique(UniqueType.ModIsAudioVisualOnly) + val modRulesets = RulesetCache.values.filter { + ModCompatibility.isExtensionMod(it) } for (mod in modRulesets.sortedBy { it.name }) { @@ -77,7 +75,7 @@ class ModCheckboxTable( baseRuleset = RulesetCache[newBaseRuleset] ?: return val compatibleMods = modWidgets - .filterNot { isIncompatible(it.mod, baseRuleset) } + .filter { ModCompatibility.meetsBaseRequirements(it.mod, baseRuleset) } if (compatibleMods.none()) return @@ -91,7 +89,8 @@ class ModCheckboxTable( it.add(mod.widget).row() } }).pad(10f).padTop(expanderPadTop).growX().row() - // I think it's not necessary to uncheck the imcompatible (now invisible) checkBoxes + + disableIncompatibleMods() runComplexModCheck() } @@ -103,6 +102,8 @@ class ModCheckboxTable( } mods.clear() disableChangeEvents = false + + disableIncompatibleMods() onUpdate("-") // should match no mod } @@ -114,6 +115,7 @@ class ModCheckboxTable( // Check over complete combination of selected mods val complexModLinkCheck = RulesetCache.checkCombinedModLinks(mods, baseRulesetName) if (!complexModLinkCheck.isWarnUser()){ + savedModcheckResult = null Gdx.input.inputProcessor = currentInputProcessor return false } @@ -167,20 +169,41 @@ class ModCheckboxTable( } + disableIncompatibleMods() + return true } - private fun modNameFilter(modName: String, filter: String): Boolean { - if (modName == filter) return true - if (filter.length < 3 || !filter.startsWith('*') || !filter.endsWith('*')) return false - val partialName = filter.substring(1, filter.length - 1).lowercase() - return partialName in modName.lowercase() + /** Deselect incompatible mods after [skipCheckBox] was selected. + * + * Note: Inactive - we don'n even allow a conflict to be turned on using [disableIncompatibleMods]. + * But if we want the alternative UX instead - use this in [checkBoxChanged] near `mods.add` and skip disabling... + */ + @Suppress("unused") + private fun deselectIncompatibleMods(skipCheckBox: CheckBox) { + disableChangeEvents = true + for (modWidget in modWidgets) { + if (modWidget.widget == skipCheckBox) continue + if (!ModCompatibility.meetsAllRequirements(modWidget.mod, baseRuleset, getSelectedMods())) { + modWidget.widget.isChecked = false + mods.remove(modWidget.mod.name) + } + } + disableChangeEvents = true } - private fun isIncompatibleWith(mod: Ruleset, otherMod: Ruleset) = - mod.modOptions.getMatchingUniques(UniqueType.ModIncompatibleWith) - .any { modNameFilter(otherMod.name, it.params[0]) } - private fun isIncompatible(mod: Ruleset, otherMod: Ruleset) = - isIncompatibleWith(mod, otherMod) || isIncompatibleWith(otherMod, mod) + /** Disable incompatible mods - those that could not be turned on with the current selection */ + private fun disableIncompatibleMods() { + for (modWidget in modWidgets) { + val enable = ModCompatibility.meetsAllRequirements(modWidget.mod, baseRuleset, getSelectedMods()) + assert(enable || !modWidget.widget.isChecked) { "Mod compatibility conflict: Trying to disable ${modWidget.mod.name} while it is selected" } + modWidget.widget.isDisabled = !enable // isEnabled is only for TextButtons + } + } + private fun getSelectedMods() = + modWidgets.asSequence() + .filter { it.widget.isChecked } + .map { it.mod } + .asIterable() } diff --git a/docs/Modders/uniques.md b/docs/Modders/uniques.md index db271106a0..2115fc9513 100644 --- a/docs/Modders/uniques.md +++ b/docs/Modders/uniques.md @@ -1779,10 +1779,17 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl ## ModOptions uniques ??? example "Mod is incompatible with [modFilter]" + Specifies that your Mod is incompatible with another. Always treated symmetrically, and cannot be overridden by the Mod you are declaring as incompatible. Example: "Mod is incompatible with [DeCiv Redux]" Applicable to: ModOptions +??? example "Mod requires [modFilter]" + Specifies that your Extension Mod is only available if any other Mod matching the filter is active. + Example: "Mod requires [DeCiv Redux]" + + Applicable to: ModOptions + ??? example "Should only be used as permanent audiovisual mod" Applicable to: ModOptions