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 <yairm210@hotmail.com>
This commit is contained in:
SomeTroglodyte 2023-12-22 08:59:38 +01:00 committed by GitHub
parent 746918b1d3
commit f529d969f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 211 additions and 50 deletions

View File

@ -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",

View File

@ -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? =

View File

@ -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),

View File

@ -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<Ruleset>): 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
}
}

View File

@ -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!"

View File

@ -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()
}
}

View File

@ -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()
}

View File

@ -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