Fix Permanent Audiovisual toggle and start on declarative Mod Compatibility (#9970)

* Fix Mod Manager not offering "Permanent Audiovisual" for audio-only mods

* Beginnings of declarative Mod Compatibility
This commit is contained in:
SomeTroglodyte 2023-08-28 09:51:14 +02:00 committed by GitHub
parent 0db070a25f
commit 96292cbf4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 126 additions and 28 deletions

View File

@ -510,6 +510,16 @@ enum class UniqueParameterType(
override fun getTranslationWriterStringsForOutput() = knownValues 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 */ /** Behaves like [Unknown], but states explicitly the parameter is OK and its contents are ignored */
Comment("comment", "comment", null, "Unique Specials") { Comment("comment", "comment", null, "Unique Specials") {
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):

View File

@ -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.Tech, UniqueTarget.Terrain, UniqueTarget.Resource, UniqueTarget.Policy, UniqueTarget.Promotion,
UniqueTarget.Nation, UniqueTarget.Ruins, flags = UniqueFlag.setOfHiddenToUsers), 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 // endregion
///////////////////////////////////////////// region 99 DEPRECATED AND REMOVED ///////////////////////////////////////////// ///////////////////////////////////////////// region 99 DEPRECATED AND REMOVED /////////////////////////////////////////////

View File

@ -1,11 +1,13 @@
package com.unciv.ui.screens.modmanager package com.unciv.ui.screens.modmanager
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Texture import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.scenes.scene2d.ui.Image import com.badlogic.gdx.scenes.scene2d.ui.Image
import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.models.metadata.BaseRuleset import com.unciv.models.metadata.BaseRuleset
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.UncivDateFormat.formatDate import com.unciv.ui.components.extensions.UncivDateFormat.formatDate
import com.unciv.ui.components.extensions.UncivDateFormat.parseDate import com.unciv.ui.components.extensions.UncivDateFormat.parseDate
@ -22,7 +24,9 @@ internal class ModInfoAndActionPane : Table() {
private val imageHolder = Table() private val imageHolder = Table()
private val sizeLabel = "".toLabel() private val sizeLabel = "".toLabel()
private var isBuiltin = false private var isBuiltin = false
private var disableVisualCheckBox = false
/** controls "Permanent audiovisual mod" checkbox existence */
private var enableVisualCheckBox = false
init { init {
defaults().pad(10f) defaults().pad(10f)
@ -33,7 +37,7 @@ internal class ModInfoAndActionPane : Table() {
*/ */
fun update(repo: Github.Repo) { fun update(repo: Github.Repo) {
isBuiltin = false isBuiltin = false
disableVisualCheckBox = true enableVisualCheckBox = false
update( update(
repo.name, repo.html_url, repo.default_branch, repo.name, repo.html_url, repo.default_branch,
repo.pushed_at, repo.owner.login, repo.size, repo.pushed_at, repo.owner.login, repo.size,
@ -48,7 +52,7 @@ internal class ModInfoAndActionPane : Table() {
val modName = mod.name val modName = mod.name
val modOptions = mod.modOptions // The ModOptions as enriched by us with GitHub metadata when originally downloaded 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 } isBuiltin = modOptions.modUrl.isEmpty() && BaseRuleset.values().any { it.fullName == modName }
disableVisualCheckBox = mod.folderLocation?.list("atlas")?.isEmpty() ?: true // Also catches isBuiltin enableVisualCheckBox = shouldShowVisualCheckbox(mod)
update( update(
modName, modOptions.modUrl, modOptions.defaultBranch, modName, modOptions.modUrl, modOptions.defaultBranch,
modOptions.lastUpdated, modOptions.author, modOptions.modSize modOptions.lastUpdated, modOptions.author, modOptions.modSize
@ -106,7 +110,7 @@ internal class ModInfoAndActionPane : Table() {
} }
fun addVisualCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) { fun addVisualCheckBox(startsOutChecked: Boolean = false, changeAction: ((Boolean)->Unit)? = null) {
if (disableVisualCheckBox) return if (enableVisualCheckBox)
add("Permanent audiovisual mod".toCheckBox(startsOutChecked, changeAction)).row() add("Permanent audiovisual mod".toCheckBox(startsOutChecked, changeAction)).row()
} }
@ -166,4 +170,25 @@ internal class ModInfoAndActionPane : Table() {
cell.size(texture.width * resizeRatio, texture.height * resizeRatio) 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()
}
} }

View File

@ -373,6 +373,7 @@ class GameOptionsTable(
// If so, add it to the current ruleset // If so, add it to the current ruleset
gameParameters.baseRuleset = newBaseRuleset gameParameters.baseRuleset = newBaseRuleset
modCheckboxes.setBaseRuleset(newBaseRuleset) // Treats declared incompatibility
onChooseMod(newBaseRuleset) onChooseMod(newBaseRuleset)
// Check if the ruleset in its entirety is still well-defined // Check if the ruleset in its entirety is still well-defined
@ -383,7 +384,6 @@ class GameOptionsTable(
} }
modLinkErrors.showWarnOrErrorToast(previousScreen as BaseScreen) modLinkErrors.showWarnOrErrorToast(previousScreen as BaseScreen)
modCheckboxes.setBaseRuleset(newBaseRuleset)
return null return null
} }

View File

@ -5,6 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.ExpanderTab import com.unciv.ui.components.ExpanderTab
import com.unciv.ui.components.extensions.pad import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toCheckBox 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. * Manages compatibility checks, warns or prevents incompatibilities.
* *
* @param mods In/out set of active mods, modified in place * @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 screen Parent screen, used only to show [ToastPopup]s
* @param isPortrait Used only for minor layout tweaks, arrangement is always vertical * @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. * @param onUpdate Callback, parameter is the mod name, called after any checks that may prevent mod selection succeed.
*/ */
class ModCheckboxTable( class ModCheckboxTable(
private val mods: LinkedHashSet<String>, private val mods: LinkedHashSet<String>,
private var baseRuleset: String, initialBaseRuleset: String,
private val screen: BaseScreen, private val screen: BaseScreen,
isPortrait: Boolean = false, isPortrait: Boolean = false,
private val onUpdate: (String) -> Unit private val onUpdate: (String) -> Unit
): Table() { ): Table() {
private val extensionRulesetModButtons = ArrayList<CheckBox>() private var baseRulesetName = ""
private lateinit var baseRuleset: Ruleset
private class ModWithCheckBox(val mod: Ruleset, val widget: CheckBox)
private val modWidgets = ArrayList<ModWithCheckBox>()
/** Saved result from any complex mod check unless the causing selection has already been reverted. /** 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 * 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 var disableChangeEvents = false
private val expanderPadTop = if (isPortrait) 0f else 16f
init { 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 }) { for (mod in modRulesets.sortedBy { it.name }) {
val checkBox = mod.name.toCheckBox(mod.name in mods) val checkBox = mod.name.toCheckBox(mod.name in mods)
checkBox.onChange { checkBox.onChange {
@ -49,29 +61,44 @@ class ModCheckboxTable(
onUpdate(mod.name) onUpdate(mod.name)
} }
} }
extensionRulesetModButtons.add(checkBox) checkBox.left()
modWidgets += ModWithCheckBox(mod, checkBox)
} }
val padTop = if (isPortrait) 0f else 16f 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
}
if (extensionRulesetModButtons.any()) {
add(ExpanderTab("Extension mods", persistenceID = "NewGameExpansionMods") { add(ExpanderTab("Extension mods", persistenceID = "NewGameExpansionMods") {
it.defaults().pad(5f,0f) it.defaults().pad(5f,0f)
for (checkbox in extensionRulesetModButtons) { for (mod in compatibleMods) {
checkbox.left() it.add(mod.widget).row()
it.add(checkbox).row()
} }
}).pad(10f).padTop(padTop).growX().row() }).pad(10f).padTop(expanderPadTop).growX().row()
// I think it's not necessary to uncheck the imcompatible (now invisible) checkBoxes
runComplexModCheck() runComplexModCheck()
} }
}
fun setBaseRuleset(newBaseRuleset: String) { baseRuleset = newBaseRuleset }
fun disableAllCheckboxes() { fun disableAllCheckboxes() {
disableChangeEvents = true disableChangeEvents = true
for (checkBox in extensionRulesetModButtons) { for (mod in modWidgets) {
checkBox.isChecked = false mod.widget.isChecked = false
} }
mods.clear() mods.clear()
disableChangeEvents = false disableChangeEvents = false
@ -80,7 +107,7 @@ class ModCheckboxTable(
private fun runComplexModCheck(): Boolean { private fun runComplexModCheck(): Boolean {
// Check over complete combination of selected mods // Check over complete combination of selected mods
val complexModLinkCheck = RulesetCache.checkCombinedModLinks(mods, baseRuleset) val complexModLinkCheck = RulesetCache.checkCombinedModLinks(mods, baseRulesetName)
if (!complexModLinkCheck.isWarnUser()) return false if (!complexModLinkCheck.isWarnUser()) return false
savedModcheckResult = complexModLinkCheck.getErrorText() savedModcheckResult = complexModLinkCheck.getErrorText()
complexModLinkCheck.showWarnOrErrorToast(screen) complexModLinkCheck.showWarnOrErrorToast(screen)
@ -132,4 +159,18 @@ class ModCheckboxTable(
return true 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)
} }

View File

@ -1728,6 +1728,21 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
??? example "Provides a unique luxury" ??? example "Provides a unique luxury"
Applicable to: CityState 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 ## Conditional uniques
!!! note "" !!! note ""
@ -2132,6 +2147,7 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
*[era]: The name of any era. *[era]: The name of any era.
*[foundingOrEnhancing]: `founding` or `enhancing`. *[foundingOrEnhancing]: `founding` or `enhancing`.
*[improvementName]: The name of any improvement. *[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. *[policy]: The name of any policy.
*[promotion]: The name of any promotion. *[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). *[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).