diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt index 2cd20af20c..88c0c10517 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt @@ -510,6 +510,16 @@ enum class UniqueParameterType( override fun getTranslationWriterStringsForOutput() = knownValues }, + /** Mod declarative compatibility: Behaves like [Unknown], but makes for nicer auto-generated documentation. */ + 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.UniqueComplianceErrorSeverity? = + if ('-' !in parameterText && ('*' !in parameterText || parameterText.matches(Regex("""^\*[^*]+\*$""")))) null + else UniqueType.UniqueComplianceErrorSeverity.RulesetInvariant + + override fun getTranslationWriterStringsForOutput() = scanExistingValues(this) + }, + /** Behaves like [Unknown], but states explicitly the parameter is OK and its contents are ignored */ Comment("comment", "comment", null, "Unique Specials") { override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index 2507e2e02e..817ffd056a 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -766,6 +766,12 @@ enum class UniqueType(val text: String, vararg targets: UniqueTarget, val flags: UniqueTarget.Tech, UniqueTarget.Terrain, UniqueTarget.Resource, UniqueTarget.Policy, UniqueTarget.Promotion, UniqueTarget.Nation, UniqueTarget.Ruins, flags = UniqueFlag.setOfHiddenToUsers), + // Declarative Mod compatibility (so far rudimentary): + ModIncompatibleWith("Mod is incompatible with [modFilter]", UniqueTarget.ModOptions), + 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), + // endregion ///////////////////////////////////////////// region 99 DEPRECATED AND REMOVED ///////////////////////////////////////////// diff --git a/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt index bb0b6d70ef..b2e8a394ba 100644 --- a/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt +++ b/core/src/com/unciv/ui/screens/modmanager/ModInfoAndActionPane.kt @@ -1,11 +1,13 @@ 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.translations.tr import com.unciv.ui.components.extensions.UncivDateFormat.formatDate import com.unciv.ui.components.extensions.UncivDateFormat.parseDate @@ -22,7 +24,9 @@ internal class ModInfoAndActionPane : Table() { private val imageHolder = Table() private val sizeLabel = "".toLabel() private var isBuiltin = false - private var disableVisualCheckBox = false + + /** controls "Permanent audiovisual mod" checkbox existence */ + private var enableVisualCheckBox = false init { defaults().pad(10f) @@ -33,7 +37,7 @@ internal class ModInfoAndActionPane : Table() { */ fun update(repo: Github.Repo) { isBuiltin = false - disableVisualCheckBox = true + enableVisualCheckBox = false update( repo.name, repo.html_url, repo.default_branch, repo.pushed_at, repo.owner.login, repo.size, @@ -48,7 +52,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 } - disableVisualCheckBox = mod.folderLocation?.list("atlas")?.isEmpty() ?: true // Also catches isBuiltin + enableVisualCheckBox = shouldShowVisualCheckbox(mod) update( modName, modOptions.modUrl, modOptions.defaultBranch, modOptions.lastUpdated, modOptions.author, modOptions.modSize @@ -106,8 +110,8 @@ internal class ModInfoAndActionPane : Table() { } fun addVisualCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) { - if (disableVisualCheckBox) return - add("Permanent audiovisual mod".toCheckBox(startsOutChecked, changeAction)).row() + if (enableVisualCheckBox) + add("Permanent audiovisual mod".toCheckBox(startsOutChecked, changeAction)).row() } fun addUpdateModButton(modInfo: ModUIData, doDownload: () -> Unit) { @@ -166,4 +170,25 @@ 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 + return folder.list("atlas").isNotEmpty() + } } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt index c743c69450..cbb9c70ca3 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/GameOptionsTable.kt @@ -373,6 +373,7 @@ class GameOptionsTable( // If so, add it to the current ruleset gameParameters.baseRuleset = newBaseRuleset + modCheckboxes.setBaseRuleset(newBaseRuleset) // Treats declared incompatibility onChooseMod(newBaseRuleset) // Check if the ruleset in its entirety is still well-defined @@ -383,7 +384,6 @@ class GameOptionsTable( } modLinkErrors.showWarnOrErrorToast(previousScreen as BaseScreen) - modCheckboxes.setBaseRuleset(newBaseRuleset) return null } diff --git a/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt b/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt index c2a5ffa0d6..bb63ec79e4 100644 --- a/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt +++ b/core/src/com/unciv/ui/screens/newgamescreen/ModCheckboxTable.kt @@ -5,6 +5,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.ui.components.ExpanderTab import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.toCheckBox @@ -17,19 +18,23 @@ import com.unciv.ui.screens.basescreen.BaseScreen * Manages compatibility checks, warns or prevents incompatibilities. * * @param mods In/out set of active mods, modified in place - * @param baseRuleset The selected base Ruleset, only for running mod checks against. Use [setBaseRuleset] to change on the fly. + * @param initialBaseRuleset The selected base Ruleset, only for running mod checks against. Use [setBaseRuleset] to change on the fly. * @param screen Parent screen, used only to show [ToastPopup]s * @param isPortrait Used only for minor layout tweaks, arrangement is always vertical * @param onUpdate Callback, parameter is the mod name, called after any checks that may prevent mod selection succeed. */ class ModCheckboxTable( private val mods: LinkedHashSet, - private var baseRuleset: String, + initialBaseRuleset: String, private val screen: BaseScreen, isPortrait: Boolean = false, private val onUpdate: (String) -> Unit ): Table() { - private val extensionRulesetModButtons = ArrayList() + private var baseRulesetName = "" + private lateinit var baseRuleset: Ruleset + + private class ModWithCheckBox(val mod: Ruleset, val widget: CheckBox) + private val modWidgets = ArrayList() /** Saved result from any complex mod check unless the causing selection has already been reverted. * In other words, this can contain the text for an "Error" level check only if the Widget was @@ -40,8 +45,15 @@ class ModCheckboxTable( private var disableChangeEvents = false + private val expanderPadTop = if (isPortrait) 0f else 16f + init { - val modRulesets = RulesetCache.values.filter { it.name != "" && !it.modOptions.isBaseRuleset} + val modRulesets = RulesetCache.values.filterNot { + it.modOptions.isBaseRuleset + || it.name.isBlank() + || it.modOptions.hasUnique(UniqueType.ModIsAudioVisualOnly) + } + for (mod in modRulesets.sortedBy { it.name }) { val checkBox = mod.name.toCheckBox(mod.name in mods) checkBox.onChange { @@ -49,29 +61,44 @@ class ModCheckboxTable( onUpdate(mod.name) } } - extensionRulesetModButtons.add(checkBox) + checkBox.left() + modWidgets += ModWithCheckBox(mod, checkBox) } - val padTop = if (isPortrait) 0f else 16f - - if (extensionRulesetModButtons.any()) { - add(ExpanderTab("Extension mods", persistenceID = "NewGameExpansionMods") { - it.defaults().pad(5f,0f) - for (checkbox in extensionRulesetModButtons) { - checkbox.left() - it.add(checkbox).row() - } - }).pad(10f).padTop(padTop).growX().row() - - runComplexModCheck() - } + setBaseRuleset(initialBaseRuleset) + } + + fun setBaseRuleset(newBaseRuleset: String) { + baseRulesetName = newBaseRuleset + savedModcheckResult = null + clear() + mods.clear() // We'll regenerate this from checked widgets + baseRuleset = RulesetCache[newBaseRuleset] ?: return + + val compatibleMods = modWidgets + .filterNot { isIncompatible(it.mod, baseRuleset) } + + if (compatibleMods.none()) return + + for (mod in compatibleMods) { + if (mod.widget.isChecked) mods += mod.mod.name + } + + add(ExpanderTab("Extension mods", persistenceID = "NewGameExpansionMods") { + it.defaults().pad(5f,0f) + for (mod in compatibleMods) { + it.add(mod.widget).row() + } + }).pad(10f).padTop(expanderPadTop).growX().row() + // I think it's not necessary to uncheck the imcompatible (now invisible) checkBoxes + + runComplexModCheck() } - fun setBaseRuleset(newBaseRuleset: String) { baseRuleset = newBaseRuleset } fun disableAllCheckboxes() { disableChangeEvents = true - for (checkBox in extensionRulesetModButtons) { - checkBox.isChecked = false + for (mod in modWidgets) { + mod.widget.isChecked = false } mods.clear() disableChangeEvents = false @@ -80,7 +107,7 @@ class ModCheckboxTable( private fun runComplexModCheck(): Boolean { // Check over complete combination of selected mods - val complexModLinkCheck = RulesetCache.checkCombinedModLinks(mods, baseRuleset) + val complexModLinkCheck = RulesetCache.checkCombinedModLinks(mods, baseRulesetName) if (!complexModLinkCheck.isWarnUser()) return false savedModcheckResult = complexModLinkCheck.getErrorText() complexModLinkCheck.showWarnOrErrorToast(screen) @@ -132,4 +159,18 @@ class ModCheckboxTable( 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() + } + + 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) + } diff --git a/docs/Modders/uniques.md b/docs/Modders/uniques.md index 596bf8e847..efad12c529 100644 --- a/docs/Modders/uniques.md +++ b/docs/Modders/uniques.md @@ -1728,6 +1728,21 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl ??? example "Provides a unique luxury" Applicable to: CityState +## ModOptions uniques +??? example "Mod is incompatible with [modFilter]" + Example: "Mod is incompatible with [DeCiv Redux]" + + Applicable to: ModOptions + +??? example "Should only be used as permanent audiovisual mod" + Applicable to: ModOptions + +??? example "Can be used as permanent audiovisual mod" + Applicable to: ModOptions + +??? example "Cannot be used as permanent audiovisual mod" + Applicable to: ModOptions + ## Conditional uniques !!! note "" @@ -2132,6 +2147,7 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl *[era]: The name of any era. *[foundingOrEnhancing]: `founding` or `enhancing`. *[improvementName]: The name of any improvement. +*[modFilter]: A Mod name, case-sensitive _or_ a simple wildcard filter beginning and ending in an Asterisk, case-insensitive. *[policy]: The name of any policy. *[promotion]: The name of any promotion. *[relativeAmount]: This indicates a number, usually with a + or - sign, such as `+25` (this kind of parameter is often followed by '%' which is nevertheless not part of the value).