diff --git a/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt b/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt index 97905b81a6..70d0be3d89 100644 --- a/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt +++ b/core/src/com/unciv/models/ruleset/validation/RulesetValidator.kt @@ -8,144 +8,41 @@ import com.unciv.json.fromJsonFile import com.unciv.json.json import com.unciv.logic.map.tile.RoadStatus import com.unciv.models.metadata.BaseRuleset -import com.unciv.models.ruleset.IRulesetObject import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.nation.getContrastRatio import com.unciv.models.ruleset.nation.getRelativeLuminance import com.unciv.models.ruleset.tile.TerrainType -import com.unciv.models.ruleset.unique.IHasUniques import com.unciv.models.ruleset.unique.StateForConditionals -import com.unciv.models.ruleset.unique.Unique -import com.unciv.models.ruleset.unique.UniqueComplianceError -import com.unciv.models.ruleset.unique.UniqueParameterType -import com.unciv.models.ruleset.unique.UniqueTarget import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.ruleset.unit.Promotion -import com.unciv.models.stats.INamed import com.unciv.models.stats.Stats import com.unciv.models.tilesets.TileSetCache import com.unciv.models.tilesets.TileSetConfig class RulesetValidator(val ruleset: Ruleset) { + val uniqueValidator = UniqueValidator(ruleset) + fun getErrorList(tryFixUnknownUniques: Boolean = false): RulesetErrorList { val lines = RulesetErrorList() /********************** Ruleset Invariant Part **********************/ - // Checks for all mods - only those that can succeed without loading a base ruleset + // Checks for ALL MODS - only those that can succeed without loading a base ruleset // When not checking the entire ruleset, we can only really detect ruleset-invariant errors in uniques val rulesetInvariant = UniqueType.UniqueComplianceErrorSeverity.RulesetInvariant val rulesetSpecific = UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific - checkUniques(ruleset.globalUniques, lines, rulesetInvariant, tryFixUnknownUniques) - - for (unit in ruleset.units.values) { - if (unit.upgradesTo == unit.name || (unit.upgradesTo != null && unit.upgradesTo == unit.replaces)) - lines += "${unit.name} upgrades to itself!" - if (!unit.isCivilian() && unit.strength == 0) - lines += "${unit.name} is a military unit but has no assigned strength!" - if (unit.isRanged() && unit.rangedStrength == 0 && !unit.hasUnique(UniqueType.CannotAttack)) - lines += "${unit.name} is a ranged unit but has no assigned rangedStrength!" - - checkUniques(unit, lines, rulesetInvariant, tryFixUnknownUniques) - } - - for (tech in ruleset.technologies.values) { - for (otherTech in ruleset.technologies.values) { - if (tech != otherTech && otherTech.column?.columnNumber == tech.column?.columnNumber && otherTech.row == tech.row) - lines += "${tech.name} is in the same row and column as ${otherTech.name}!" - } - - checkUniques(tech, lines, rulesetInvariant, tryFixUnknownUniques) - } - - for (techColumn in ruleset.techColumns){ - if (techColumn.columnNumber < 0) - lines += "Tech Column number ${techColumn.columnNumber} is negative" - if (techColumn.buildingCost == -1) - lines.add("Tech Column number ${techColumn.columnNumber} has no explicit building cost", - RulesetErrorSeverity.Warning) - if (techColumn.wonderCost == -1) - lines.add("Tech Column number ${techColumn.columnNumber} has no explicit wonder cost", - RulesetErrorSeverity.Warning) - } - - for (building in ruleset.buildings.values) { - if (building.requiredTech == null && building.cost == -1 && !building.hasUnique( - UniqueType.Unbuildable)) - lines.add("${building.name} is buildable and therefore should either have an explicit cost or reference an existing tech!", - RulesetErrorSeverity.Warning) - - for (gpp in building.greatPersonPoints) - if (gpp.key !in ruleset.units) - lines.add("Building ${building.name} has greatPersonPoints for ${gpp.key}, which is not a unit in the ruleset!", - RulesetErrorSeverity.Warning) - - checkUniques(building, lines, rulesetInvariant, tryFixUnknownUniques) - - } - - for (nation in ruleset.nations.values) { - if (nation.cities.isEmpty() && !nation.isSpectator && !nation.isBarbarian) { - lines += "${nation.name} can settle cities, but has no city names!" - } - - // https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast - val constrastRatio = nation.getContrastRatio() - if (constrastRatio < 3) { - val innerColorLuminance = getRelativeLuminance(nation.getInnerColor()) - val outerColorLuminance = getRelativeLuminance(nation.getOuterColor()) - - val innerLerpColor:Color - val outerLerpColor:Color - - if (innerColorLuminance > outerColorLuminance) { // inner is brighter - innerLerpColor = Color.WHITE - outerLerpColor = Color.BLACK - } - else { - innerLerpColor = Color.BLACK - outerLerpColor = Color.WHITE - } - - var text = "${nation.name}'s colors do not contrast enough - it is unreadable!" - - for (i in 1..10){ - val newInnerColor = nation.getInnerColor().cpy().lerp(innerLerpColor, 0.05f *i) - val newOuterColor = nation.getOuterColor().cpy().lerp(outerLerpColor, 0.05f *i) - - if (getContrastRatio(newInnerColor, newOuterColor) > 3){ - text += "\nSuggested colors: " - text += "\n\t\t\"outerColor\": [${(newOuterColor.r*255).toInt()}, ${(newOuterColor.g*255).toInt()}, ${(newOuterColor.b*255).toInt()}]," - text += "\n\t\t\"innerColor\": [${(newInnerColor.r*255).toInt()}, ${(newInnerColor.g*255).toInt()}, ${(newInnerColor.b*255).toInt()}]," - break - } - } - - lines.add( - text, RulesetErrorSeverity.WarningOptionsOnly - ) - } - - checkUniques(nation, lines, rulesetInvariant, tryFixUnknownUniques) - } - - for (promotion in ruleset.unitPromotions.values) { - checkUniques(promotion, lines, rulesetInvariant, tryFixUnknownUniques) - if (promotion.row < -1) lines += "Promotion ${promotion.name} has invalid row value: ${promotion.row}" - if (promotion.column < 0) lines += "Promotion ${promotion.name} has invalid column value: ${promotion.column}" - if (promotion.row == -1) continue - for (otherPromotion in ruleset.unitPromotions.values) - if (promotion != otherPromotion && promotion.column == otherPromotion.column && promotion.row == otherPromotion.row) - lines += "Promotions ${promotion.name} and ${otherPromotion.name} have the same position: ${promotion.row}/${promotion.column}" - } - - for (resource in ruleset.tileResources.values) { - checkUniques(resource, lines, rulesetInvariant, tryFixUnknownUniques) - } + uniqueValidator.checkUniques(ruleset.globalUniques, lines, rulesetInvariant, tryFixUnknownUniques) + addUnitErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) + addTechErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) + addTechColumnErrorsRulesetInvariant(lines) + addBuildingErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) + addNationErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) + addPromotionErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) + addResourceErrorsRulesetInvariant(lines, rulesetInvariant, tryFixUnknownUniques) /********************** Tileset tests **********************/ // e.g. json configs complete and parseable @@ -159,54 +56,290 @@ class RulesetValidator(val ruleset: Ruleset) { /********************** Ruleset Specific Part **********************/ - checkUniques(ruleset.globalUniques, lines, rulesetSpecific, tryFixUnknownUniques) + uniqueValidator.checkUniques(ruleset.globalUniques, lines, rulesetSpecific, tryFixUnknownUniques) - if (ruleset.units.values.none { it.isCityFounder() }) - lines += "No city-founding units in ruleset!" + addUnitErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addBuildingErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addSpecialistErrors(lines) + addResourceErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addImprovementErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addTerrainErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addTechErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addEraErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addSpeedErrors(lines) + addBeliefErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addNationErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addPolicyErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addRuinsErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addPromotionErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addUnitTypeErrors(lines, rulesetSpecific, tryFixUnknownUniques) + addVictoryTypeErrors(lines) + addDifficutlyErrors(lines) + addCityStateTypeErrors(tryFixUnknownUniques, rulesetSpecific, lines) - for (unit in ruleset.units.values) { - checkUnitRulesetSpecific(unit, lines) - checkUniques(unit, lines, rulesetSpecific, tryFixUnknownUniques) + return lines + } + + private fun addCityStateTypeErrors( + tryFixUnknownUniques: Boolean, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + lines: RulesetErrorList + ) { + for (cityStateType in ruleset.cityStateTypes.values) { + for (unique in cityStateType.allyBonusUniqueMap.getAllUniques() + cityStateType.friendBonusUniqueMap.getAllUniques()) { + val errors = uniqueValidator.checkUnique( + unique, + tryFixUnknownUniques, + cityStateType, + rulesetSpecific + ) + lines.addAll(errors) + } + } + } + + private fun addDifficutlyErrors(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)) + lines += "Difficulty ${difficulty.name} contains starting unit $unitName which does not exist!" + } + } + + private fun addVictoryTypeErrors(lines: RulesetErrorList) { + for (victoryType in ruleset.victories.values) { + for (requiredUnit in victoryType.requiredSpaceshipParts) + if (!ruleset.units.contains(requiredUnit)) + lines.add( + "Victory type ${victoryType.name} requires adding the non-existant unit $requiredUnit to the capital to win!", + RulesetErrorSeverity.Warning + ) + for (milestone in victoryType.milestoneObjects) + if (milestone.type == null) + lines.add( + "Victory type ${victoryType.name} has milestone ${milestone.uniqueDescription} that is of an unknown type!", + RulesetErrorSeverity.Error + ) + for (victory in ruleset.victories.values) + if (victory.name != victoryType.name && victory.milestones == victoryType.milestones) + lines.add( + "Victory types ${victoryType.name} and ${victory.name} have the same requirements!", + RulesetErrorSeverity.Warning + ) + } + } + + private fun addUnitTypeErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (unitType in ruleset.unitTypes.values) { + uniqueValidator.checkUniques(unitType, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addPromotionErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (promotion in ruleset.unitPromotions.values) { + // These are warning as of 3.17.5 to not break existing mods and give them time to correct, should be upgraded to error in the future + for (prereq in promotion.prerequisites) + if (!ruleset.unitPromotions.containsKey(prereq)) + lines.add( + "${promotion.name} requires promotion $prereq which does not exist!", + RulesetErrorSeverity.Warning + ) + for (unitType in promotion.unitTypes) checkUnitType(unitType) { + lines.add( + "${promotion.name} references unit type $unitType, which does not exist!", + RulesetErrorSeverity.Warning + ) + } + uniqueValidator.checkUniques(promotion, lines, rulesetSpecific, tryFixUnknownUniques) + } + checkPromotionCircularReferences(lines) + } + + private fun addRuinsErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (reward in ruleset.ruinRewards.values) { + for (difficulty in reward.excludedDifficulties) + if (!ruleset.difficulties.containsKey(difficulty)) + lines += "${reward.name} references difficulty ${difficulty}, which does not exist!" + uniqueValidator.checkUniques(reward, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addPolicyErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (policy in ruleset.policies.values) { + if (policy.requires != null) + for (prereq in policy.requires!!) + if (!ruleset.policies.containsKey(prereq)) + lines += "${policy.name} requires policy $prereq which does not exist!" + uniqueValidator.checkUniques(policy, lines, rulesetSpecific, tryFixUnknownUniques) } - for (building in ruleset.buildings.values) { - if (building.requiredTech != null && !ruleset.technologies.containsKey(building.requiredTech!!)) - lines += "${building.name} requires tech ${building.requiredTech} which does not exist!" + for (branch in ruleset.policyBranches.values) + if (branch.era !in ruleset.eras) + lines += "${branch.name} requires era ${branch.era} 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!" - for (resource in building.getResourceRequirementsPerTurn().keys) - if (!ruleset.tileResources.containsKey(resource)) - lines += "${building.name} requires resource $resource which does not exist!" - if (building.replaces != null && !ruleset.buildings.containsKey(building.replaces!!)) - lines += "${building.name} replaces ${building.replaces} which does not exist!" - if (building.requiredBuilding != null && !ruleset.buildings.containsKey(building.requiredBuilding!!)) - lines += "${building.name} requires ${building.requiredBuilding} which does not exist!" - checkUniques(building, lines, rulesetSpecific, tryFixUnknownUniques) + + for (policy in ruleset.policyBranches.values.flatMap { it.policies + it }) + if (policy != ruleset.policies[policy.name]) + lines += "More than one policy with the name ${policy.name} exists!" + + } + + private fun addNationErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (nation in ruleset.nations.values) { + uniqueValidator.checkUniques(nation, lines, rulesetSpecific, tryFixUnknownUniques) + + if (nation.cityStateType != null && nation.cityStateType !in ruleset.cityStateTypes) + lines += "${nation.name} is of city-state type ${nation.cityStateType} which does not exist!" + if (nation.favoredReligion != null && nation.favoredReligion !in ruleset.religions) + lines += "${nation.name} has ${nation.favoredReligion} as their favored religion, which does not exist!" + } + } + + private fun addBeliefErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (belief in ruleset.beliefs.values) { + uniqueValidator.checkUniques(belief, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addSpeedErrors(lines: RulesetErrorList) { + for (speed in ruleset.speeds.values) { + if (speed.modifier < 0f) + lines += "Negative speed modifier for game speed ${speed.name}" + if (speed.yearsPerTurn.isEmpty()) + lines += "Empty turn increment list for game speed ${speed.name}" + } + } + + private fun addEraErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + if (ruleset.eras.isEmpty()) { + lines += "Eras file is empty! This will likely lead to crashes. Ask the mod maker to update this mod!" } - for (specialist in ruleset.specialists.values){ - for (gpp in specialist.greatPersonPoints) - if (gpp.key !in ruleset.units) - lines.add("Specialist ${specialist.name} has greatPersonPoints for ${gpp.key}, which is not a unit in the ruleset!", - RulesetErrorSeverity.Warning) + val allDifficultiesStartingUnits = hashSetOf() + for (difficulty in ruleset.difficulties.values) { + allDifficultiesStartingUnits.addAll(difficulty.aiCityStateBonusStartingUnits) + allDifficultiesStartingUnits.addAll(difficulty.aiMajorCivBonusStartingUnits) + allDifficultiesStartingUnits.addAll(difficulty.playerBonusStartingUnits) } - for (resource in ruleset.tileResources.values) { - if (resource.revealedBy != null && !ruleset.technologies.containsKey(resource.revealedBy!!)) - lines += "${resource.name} revealed by tech ${resource.revealedBy} which does not exist!" - if (resource.improvement != null && !ruleset.tileImprovements.containsKey(resource.improvement!!)) - lines += "${resource.name} improved by improvement ${resource.improvement} which does not exist!" - for (improvement in resource.improvedBy) - if (!ruleset.tileImprovements.containsKey(improvement)) - lines += "${resource.name} improved by improvement $improvement which does not exist!" - for (terrain in resource.terrainsCanBeFoundOn) - if (!ruleset.terrains.containsKey(terrain)) - lines += "${resource.name} can be found on terrain $terrain which does not exist!" - checkUniques(resource, lines, rulesetSpecific, tryFixUnknownUniques) - } + for (era in ruleset.eras.values) { + for (wonder in era.startingObsoleteWonders) + if (wonder !in ruleset.buildings) + lines += "Nonexistent wonder $wonder obsoleted when starting in ${era.name}!" + for (building in era.settlerBuildings) + if (building !in ruleset.buildings) + lines += "Nonexistent building $building built by settlers when starting in ${era.name}" + // todo the whole 'starting unit' thing needs to be redone, there's no reason we can't have a single list containing all the starting units. + if (era.startingSettlerUnit !in ruleset.units + && ruleset.units.values.none { it.isCityFounder() } + ) + lines += "Nonexistent unit ${era.startingSettlerUnit} marked as starting unit when starting in ${era.name}" + if (era.startingWorkerCount != 0 && era.startingWorkerUnit !in ruleset.units + && ruleset.units.values.none { it.hasUnique(UniqueType.BuildImprovements) } + ) + lines += "Nonexistent unit ${era.startingWorkerUnit} marked as starting unit when starting in ${era.name}" + if ((era.startingMilitaryUnitCount != 0 || allDifficultiesStartingUnits.contains( + Constants.eraSpecificUnit + )) && era.startingMilitaryUnit !in ruleset.units + ) + lines += "Nonexistent unit ${era.startingMilitaryUnit} marked as starting unit when starting in ${era.name}" + if (era.researchAgreementCost < 0 || era.startingSettlerCount < 0 || era.startingWorkerCount < 0 || era.startingMilitaryUnitCount < 0 || era.startingGold < 0 || era.startingCulture < 0) + lines += "Unexpected negative number found while parsing era ${era.name}" + if (era.settlerPopulation <= 0) + lines += "Population in cities from settlers must be strictly positive! Found value ${era.settlerPopulation} for era ${era.name}" + + if (era.allyBonus.isNotEmpty()) + lines.add( + "Era ${era.name} contains city-state bonuses. City-state bonuses are now defined in CityStateType.json", + RulesetErrorSeverity.WarningOptionsOnly + ) + if (era.friendBonus.isNotEmpty()) + lines.add( + "Era ${era.name} contains city-state bonuses. City-state bonuses are now defined in CityStateType.json", + RulesetErrorSeverity.WarningOptionsOnly + ) + + uniqueValidator.checkUniques(era, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addTechErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (tech in ruleset.technologies.values) { + for (prereq in tech.prerequisites) { + if (!ruleset.technologies.containsKey(prereq)) + lines += "${tech.name} requires tech $prereq which does not exist!" + + + if (tech.prerequisites.asSequence().filterNot { it == prereq } + .any { getPrereqTree(it).contains(prereq) }) { + lines.add( + "No need to add $prereq as a prerequisite of ${tech.name} - it is already implicit from the other prerequisites!", + RulesetErrorSeverity.Warning + ) + } + + if (getPrereqTree(prereq).contains(tech.name)) + lines += "Techs ${tech.name} and $prereq require each other!" + } + if (tech.era() !in ruleset.eras) + lines += "Unknown era ${tech.era()} referenced in column of tech ${tech.name}" + uniqueValidator.checkUniques(tech, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addTerrainErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + if (ruleset.terrains.values.none { it.type == TerrainType.Land && !it.impassable }) + lines += "No passable land terrains exist!" + for (terrain in ruleset.terrains.values) { + for (baseTerrain in terrain.occursOn) + if (!ruleset.terrains.containsKey(baseTerrain)) + lines += "${terrain.name} occurs on terrain $baseTerrain which does not exist!" + uniqueValidator.checkUniques(terrain, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addImprovementErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { for (improvement in ruleset.tileImprovements.values) { if (improvement.techRequired != null && !ruleset.technologies.containsKey(improvement.techRequired!!)) lines += "${improvement.name} requires tech ${improvement.techRequired} which does not exist!" @@ -214,11 +347,11 @@ class RulesetValidator(val ruleset: Ruleset) { if (!ruleset.terrains.containsKey(terrain) && terrain != "Land" && terrain != "Water") lines += "${improvement.name} can be built on terrain $terrain which does not exist!" if (improvement.terrainsCanBeBuiltOn.isEmpty() - && !improvement.hasUnique(UniqueType.CanOnlyImproveResource) - && !improvement.hasUnique(UniqueType.Unbuildable) - && !improvement.name.startsWith(Constants.remove) - && improvement.name !in RoadStatus.values().map { it.removeAction } - && improvement.name != Constants.cancelImprovementOrder + && !improvement.hasUnique(UniqueType.CanOnlyImproveResource) + && !improvement.hasUnique(UniqueType.Unbuildable) + && !improvement.name.startsWith(Constants.remove) + && improvement.name !in RoadStatus.values().map { it.removeAction } + && improvement.name != Constants.cancelImprovementOrder ) { lines.add( "${improvement.name} has an empty `terrainsCanBeBuiltOn`, isn't allowed to only improve resources and isn't unbuildable! Support for this will soon end. Either give this the unique \"Unbuildable\", \"Can only be built to improve a resource\" or add \"Land\", \"Water\" or any other value to `terrainsCanBeBuiltOn`.", @@ -235,186 +368,246 @@ class RulesetValidator(val ruleset: Ruleset) { ) } } - if ((improvement.hasUnique(UniqueType.PillageYieldRandom, StateForConditionals.IgnoreConditionals) - || improvement.hasUnique(UniqueType.PillageYieldFixed, StateForConditionals.IgnoreConditionals)) - && improvement.hasUnique(UniqueType.Unpillagable, StateForConditionals.IgnoreConditionals)) { + if ((improvement.hasUnique( + UniqueType.PillageYieldRandom, + StateForConditionals.IgnoreConditionals + ) + || improvement.hasUnique( + UniqueType.PillageYieldFixed, + StateForConditionals.IgnoreConditionals + )) + && improvement.hasUnique( + UniqueType.Unpillagable, + StateForConditionals.IgnoreConditionals + ) + ) { lines.add( "${improvement.name} has both an `Unpillagable` unique type and a `PillageYieldRandom` or `PillageYieldFixed` unique type!", RulesetErrorSeverity.Warning ) } - checkUniques(improvement, lines, rulesetSpecific, tryFixUnknownUniques) + uniqueValidator.checkUniques(improvement, lines, rulesetSpecific, tryFixUnknownUniques) } + } - if (ruleset.terrains.values.none { it.type == TerrainType.Land && !it.impassable }) - lines += "No passable land terrains exist!" - for (terrain in ruleset.terrains.values) { - for (baseTerrain in terrain.occursOn) - if (!ruleset.terrains.containsKey(baseTerrain)) - lines += "${terrain.name} occurs on terrain $baseTerrain which does not exist!" - checkUniques(terrain, lines, rulesetSpecific, tryFixUnknownUniques) + private fun addResourceErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (resource in ruleset.tileResources.values) { + if (resource.revealedBy != null && !ruleset.technologies.containsKey(resource.revealedBy!!)) + lines += "${resource.name} revealed by tech ${resource.revealedBy} which does not exist!" + if (resource.improvement != null && !ruleset.tileImprovements.containsKey(resource.improvement!!)) + lines += "${resource.name} improved by improvement ${resource.improvement} which does not exist!" + for (improvement in resource.improvedBy) + if (!ruleset.tileImprovements.containsKey(improvement)) + lines += "${resource.name} improved by improvement $improvement which does not exist!" + for (terrain in resource.terrainsCanBeFoundOn) + if (!ruleset.terrains.containsKey(terrain)) + lines += "${resource.name} can be found on terrain $terrain which does not exist!" + uniqueValidator.checkUniques(resource, lines, rulesetSpecific, tryFixUnknownUniques) } + } - for (tech in ruleset.technologies.values) { - for (prereq in tech.prerequisites) { - if (!ruleset.technologies.containsKey(prereq)) - lines += "${tech.name} requires tech $prereq which does not exist!" + private fun addSpecialistErrors(lines: RulesetErrorList) { + for (specialist in ruleset.specialists.values) { + for (gpp in specialist.greatPersonPoints) + if (gpp.key !in ruleset.units) + lines.add( + "Specialist ${specialist.name} has greatPersonPoints for ${gpp.key}, which is not a unit in the ruleset!", + RulesetErrorSeverity.Warning + ) + } + } + private fun addBuildingErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (building in ruleset.buildings.values) { + if (building.requiredTech != null && !ruleset.technologies.containsKey(building.requiredTech!!)) + lines += "${building.name} requires tech ${building.requiredTech} which does not exist!" - if (tech.prerequisites.asSequence().filterNot { it == prereq } - .any { getPrereqTree(it).contains(prereq) }){ - lines.add("No need to add $prereq as a prerequisite of ${tech.name} - it is already implicit from the other prerequisites!", - RulesetErrorSeverity.Warning) + for (specialistName in building.specialistSlots.keys) + if (!ruleset.specialists.containsKey(specialistName)) + lines += "${building.name} provides specialist $specialistName which does not exist!" + for (resource in building.getResourceRequirementsPerTurn().keys) + if (!ruleset.tileResources.containsKey(resource)) + lines += "${building.name} requires resource $resource which does not exist!" + if (building.replaces != null && !ruleset.buildings.containsKey(building.replaces!!)) + lines += "${building.name} replaces ${building.replaces} which does not exist!" + if (building.requiredBuilding != null && !ruleset.buildings.containsKey(building.requiredBuilding!!)) + lines += "${building.name} requires ${building.requiredBuilding} which does not exist!" + uniqueValidator.checkUniques(building, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addUnitErrors( + lines: RulesetErrorList, + rulesetSpecific: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + if (ruleset.units.values.none { it.isCityFounder() }) + lines += "No city-founding units in ruleset!" + + for (unit in ruleset.units.values) { + checkUnitRulesetSpecific(unit, lines) + uniqueValidator.checkUniques(unit, lines, rulesetSpecific, tryFixUnknownUniques) + } + } + + private fun addResourceErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (resource in ruleset.tileResources.values) { + uniqueValidator.checkUniques(resource, lines, rulesetInvariant, tryFixUnknownUniques) + } + } + + private fun addPromotionErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (promotion in ruleset.unitPromotions.values) { + uniqueValidator.checkUniques(promotion, lines, rulesetInvariant, tryFixUnknownUniques) + if (promotion.row < -1) lines += "Promotion ${promotion.name} has invalid row value: ${promotion.row}" + if (promotion.column < 0) lines += "Promotion ${promotion.name} has invalid column value: ${promotion.column}" + if (promotion.row == -1) continue + for (otherPromotion in ruleset.unitPromotions.values) + if (promotion != otherPromotion && promotion.column == otherPromotion.column && promotion.row == otherPromotion.row) + lines += "Promotions ${promotion.name} and ${otherPromotion.name} have the same position: ${promotion.row}/${promotion.column}" + } + } + + private fun addNationErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (nation in ruleset.nations.values) { + if (nation.cities.isEmpty() && !nation.isSpectator && !nation.isBarbarian) { + lines += "${nation.name} can settle cities, but has no city names!" + } + + // https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast + val constrastRatio = nation.getContrastRatio() + if (constrastRatio < 3) { + val innerColorLuminance = getRelativeLuminance(nation.getInnerColor()) + val outerColorLuminance = getRelativeLuminance(nation.getOuterColor()) + + val innerLerpColor: Color + val outerLerpColor: Color + + if (innerColorLuminance > outerColorLuminance) { // inner is brighter + innerLerpColor = Color.WHITE + outerLerpColor = Color.BLACK + } else { + innerLerpColor = Color.BLACK + outerLerpColor = Color.WHITE } - if (getPrereqTree(prereq).contains(tech.name)) - lines += "Techs ${tech.name} and $prereq require each other!" - } - if (tech.era() !in ruleset.eras) - lines += "Unknown era ${tech.era()} referenced in column of tech ${tech.name}" - checkUniques(tech, lines, rulesetSpecific, tryFixUnknownUniques) - } + var text = "${nation.name}'s colors do not contrast enough - it is unreadable!" - if (ruleset.eras.isEmpty()) { - lines += "Eras file is empty! This will likely lead to crashes. Ask the mod maker to update this mod!" - } + for (i in 1..10) { + val newInnerColor = nation.getInnerColor().cpy().lerp(innerLerpColor, 0.05f * i) + val newOuterColor = nation.getOuterColor().cpy().lerp(outerLerpColor, 0.05f * i) - val allDifficultiesStartingUnits = hashSetOf() - for (difficulty in ruleset.difficulties.values){ - allDifficultiesStartingUnits.addAll(difficulty.aiCityStateBonusStartingUnits) - allDifficultiesStartingUnits.addAll(difficulty.aiMajorCivBonusStartingUnits) - allDifficultiesStartingUnits.addAll(difficulty.playerBonusStartingUnits) - } + if (getContrastRatio(newInnerColor, newOuterColor) > 3) { + text += "\nSuggested colors: " + text += "\n\t\t\"outerColor\": [${(newOuterColor.r * 255).toInt()}, ${(newOuterColor.g * 255).toInt()}, ${(newOuterColor.b * 255).toInt()}]," + text += "\n\t\t\"innerColor\": [${(newInnerColor.r * 255).toInt()}, ${(newInnerColor.g * 255).toInt()}, ${(newInnerColor.b * 255).toInt()}]," + break + } + } - for (era in ruleset.eras.values) { - for (wonder in era.startingObsoleteWonders) - if (wonder !in ruleset.buildings) - lines += "Nonexistent wonder $wonder obsoleted when starting in ${era.name}!" - for (building in era.settlerBuildings) - if (building !in ruleset.buildings) - lines += "Nonexistent building $building built by settlers when starting in ${era.name}" - // todo the whole 'starting unit' thing needs to be redone, there's no reason we can't have a single list containing all the starting units. - if (era.startingSettlerUnit !in ruleset.units - && ruleset.units.values.none { it.isCityFounder() }) - lines += "Nonexistent unit ${era.startingSettlerUnit} marked as starting unit when starting in ${era.name}" - if (era.startingWorkerCount != 0 && era.startingWorkerUnit !in ruleset.units - && ruleset.units.values.none { it.hasUnique(UniqueType.BuildImprovements) }) - lines += "Nonexistent unit ${era.startingWorkerUnit} marked as starting unit when starting in ${era.name}" - - if ((era.startingMilitaryUnitCount != 0 || allDifficultiesStartingUnits.contains( - Constants.eraSpecificUnit)) && era.startingMilitaryUnit !in ruleset.units) - lines += "Nonexistent unit ${era.startingMilitaryUnit} marked as starting unit when starting in ${era.name}" - if (era.researchAgreementCost < 0 || era.startingSettlerCount < 0 || era.startingWorkerCount < 0 || era.startingMilitaryUnitCount < 0 || era.startingGold < 0 || era.startingCulture < 0) - lines += "Unexpected negative number found while parsing era ${era.name}" - if (era.settlerPopulation <= 0) - lines += "Population in cities from settlers must be strictly positive! Found value ${era.settlerPopulation} for era ${era.name}" - - if (era.allyBonus.isNotEmpty()) - lines.add("Era ${era.name} contains city-state bonuses. City-state bonuses are now defined in CityStateType.json", - RulesetErrorSeverity.WarningOptionsOnly) - if (era.friendBonus.isNotEmpty()) - lines.add("Era ${era.name} contains city-state bonuses. City-state bonuses are now defined in CityStateType.json", - RulesetErrorSeverity.WarningOptionsOnly) - - checkUniques(era, lines, rulesetSpecific, tryFixUnknownUniques) - } - - for (speed in ruleset.speeds.values) { - if (speed.modifier < 0f) - lines += "Negative speed modifier for game speed ${speed.name}" - if (speed.yearsPerTurn.isEmpty()) - lines += "Empty turn increment list for game speed ${speed.name}" - } - - for (belief in ruleset.beliefs.values) { - checkUniques(belief, lines, rulesetSpecific, tryFixUnknownUniques) - } - - for (nation in ruleset.nations.values) { - checkUniques(nation, lines, rulesetSpecific, tryFixUnknownUniques) - - if (nation.cityStateType!=null && nation.cityStateType !in ruleset.cityStateTypes) - lines += "${nation.name} is of city-state type ${nation.cityStateType} which does not exist!" - if (nation.favoredReligion != null && nation.favoredReligion !in ruleset.religions) - lines += "${nation.name} has ${nation.favoredReligion} as their favored religion, which does not exist!" - } - - for (policy in ruleset.policies.values) { - if (policy.requires != null) - for (prereq in policy.requires!!) - if (!ruleset.policies.containsKey(prereq)) - lines += "${policy.name} requires policy $prereq which does not exist!" - checkUniques(policy, lines, rulesetSpecific, tryFixUnknownUniques) - } - - for (branch in ruleset.policyBranches.values) - if (branch.era !in ruleset.eras) - lines += "${branch.name} requires era ${branch.era} which does not exist!" - - - for (policy in ruleset.policyBranches.values.flatMap { it.policies + it }) - if (policy != ruleset.policies[policy.name]) - lines += "More than one policy with the name ${policy.name} exists!" - - for (reward in ruleset.ruinRewards.values) { - for (difficulty in reward.excludedDifficulties) - if (!ruleset.difficulties.containsKey(difficulty)) - lines += "${reward.name} references difficulty ${difficulty}, which does not exist!" - checkUniques(reward, lines, rulesetSpecific, tryFixUnknownUniques) - } - - for (promotion in ruleset.unitPromotions.values) { - // These are warning as of 3.17.5 to not break existing mods and give them time to correct, should be upgraded to error in the future - for (prereq in promotion.prerequisites) - if (!ruleset.unitPromotions.containsKey(prereq)) - lines.add("${promotion.name} requires promotion $prereq which does not exist!", - RulesetErrorSeverity.Warning) - for (unitType in promotion.unitTypes) checkUnitType(unitType) { - lines.add("${promotion.name} references unit type $unitType, which does not exist!", - RulesetErrorSeverity.Warning) - } - checkUniques(promotion, lines, rulesetSpecific, tryFixUnknownUniques) - } - checkPromotionCircularReferences(lines) - - for (unitType in ruleset.unitTypes.values) { - checkUniques(unitType, lines, rulesetSpecific, tryFixUnknownUniques) - } - - for (victoryType in ruleset.victories.values) { - for (requiredUnit in victoryType.requiredSpaceshipParts) - if (!ruleset.units.contains(requiredUnit)) - lines.add("Victory type ${victoryType.name} requires adding the non-existant unit $requiredUnit to the capital to win!", - RulesetErrorSeverity.Warning) - for (milestone in victoryType.milestoneObjects) - if (milestone.type == null) - lines.add("Victory type ${victoryType.name} has milestone ${milestone.uniqueDescription} that is of an unknown type!", - RulesetErrorSeverity.Error) - for (victory in ruleset.victories.values) - if (victory.name != victoryType.name && victory.milestones == victoryType.milestones) - lines.add("Victory types ${victoryType.name} and ${victory.name} have the same requirements!", - RulesetErrorSeverity.Warning) - } - - for (difficulty in ruleset.difficulties.values) { - for (unitName in difficulty.aiCityStateBonusStartingUnits + difficulty.aiMajorCivBonusStartingUnits + difficulty.playerBonusStartingUnits) - if (unitName != Constants.eraSpecificUnit && !ruleset.units.containsKey(unitName)) - lines += "Difficulty ${difficulty.name} contains starting unit $unitName which does not exist!" - } - - for (cityStateType in ruleset.cityStateTypes.values) { - for (unique in cityStateType.allyBonusUniqueMap.getAllUniques() + cityStateType.friendBonusUniqueMap.getAllUniques()){ - val errors = checkUnique( - unique, - tryFixUnknownUniques, - cityStateType, - rulesetSpecific + lines.add( + text, RulesetErrorSeverity.WarningOptionsOnly ) - lines.addAll(errors) } - } - return lines + uniqueValidator.checkUniques(nation, lines, rulesetInvariant, tryFixUnknownUniques) + } + } + + private fun addBuildingErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (building in ruleset.buildings.values) { + if (building.requiredTech == null && building.cost == -1 && !building.hasUnique( + UniqueType.Unbuildable + ) + ) + lines.add( + "${building.name} is buildable and therefore should either have an explicit cost or reference an existing tech!", + RulesetErrorSeverity.Warning + ) + + for (gpp in building.greatPersonPoints) + if (gpp.key !in ruleset.units) + lines.add( + "Building ${building.name} has greatPersonPoints for ${gpp.key}, which is not a unit in the ruleset!", + RulesetErrorSeverity.Warning + ) + + uniqueValidator.checkUniques(building, lines, rulesetInvariant, tryFixUnknownUniques) + + } + } + + private fun addTechColumnErrorsRulesetInvariant(lines: RulesetErrorList) { + for (techColumn in ruleset.techColumns) { + if (techColumn.columnNumber < 0) + lines += "Tech Column number ${techColumn.columnNumber} is negative" + if (techColumn.buildingCost == -1) + lines.add( + "Tech Column number ${techColumn.columnNumber} has no explicit building cost", + RulesetErrorSeverity.Warning + ) + if (techColumn.wonderCost == -1) + lines.add( + "Tech Column number ${techColumn.columnNumber} has no explicit wonder cost", + RulesetErrorSeverity.Warning + ) + } + } + + private fun addTechErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (tech in ruleset.technologies.values) { + for (otherTech in ruleset.technologies.values) { + if (tech != otherTech && otherTech.column?.columnNumber == tech.column?.columnNumber && otherTech.row == tech.row) + lines += "${tech.name} is in the same row and column as ${otherTech.name}!" + } + + uniqueValidator.checkUniques(tech, lines, rulesetInvariant, tryFixUnknownUniques) + } + } + + private fun addUnitErrorsRulesetInvariant( + lines: RulesetErrorList, + rulesetInvariant: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (unit in ruleset.units.values) { + if (unit.upgradesTo == unit.name || (unit.upgradesTo != null && unit.upgradesTo == unit.replaces)) + lines += "${unit.name} upgrades to itself!" + if (!unit.isCivilian() && unit.strength == 0) + lines += "${unit.name} is a military unit but has no assigned strength!" + if (unit.isRanged() && unit.rangedStrength == 0 && !unit.hasUnique(UniqueType.CannotAttack)) + lines += "${unit.name} is a ranged unit but has no assigned rangedStrength!" + + uniqueValidator.checkUniques(unit, lines, rulesetInvariant, tryFixUnknownUniques) + } } /** Collects known technology prerequisite paths: key is the technology name, @@ -469,7 +662,7 @@ class RulesetValidator(val ruleset: Ruleset) { } for (unique in unit.getMatchingUniques(UniqueType.ConstructImprovementInstantly)) { val improvementName = unique.params[0] - if (ruleset.tileImprovements[improvementName]==null) continue // this will be caught in the checkUniques + if (ruleset.tileImprovements[improvementName]==null) continue // this will be caught in the uniqueValidator.checkUniques if ((ruleset.tileImprovements[improvementName] as Stats).none() && unit.isCivilian() && !unit.isGreatPersonOfType("War")) { @@ -619,189 +812,4 @@ class RulesetValidator(val ruleset: Ruleset) { } - private fun checkUniques( - uniqueContainer: IHasUniques, - lines: RulesetErrorList, - severityToReport: UniqueType.UniqueComplianceErrorSeverity, - tryFixUnknownUniques: Boolean - ) { - for (unique in uniqueContainer.uniqueObjects) { - val errors = checkUnique( - unique, - tryFixUnknownUniques, - uniqueContainer as? INamed, - severityToReport - ) - lines.addAll(errors) - } - } - - fun checkUnique( - unique: Unique, - tryFixUnknownUniques: Boolean, - namedObj: INamed?, - severityToReport: UniqueType.UniqueComplianceErrorSeverity - ): List { - val prefix by lazy { (if (namedObj is IRulesetObject) "${namedObj.originRuleset}: " else "") + - (if (namedObj == null) "The" else "${namedObj.name}'s") } - if (unique.type == null) return checkUntypedUnique(unique, tryFixUnknownUniques, prefix) - - val rulesetErrors = RulesetErrorList() - - if (namedObj is IHasUniques && !unique.type.canAcceptUniqueTarget(namedObj.getUniqueTarget())) - rulesetErrors.add(RulesetError("$prefix unique \"${unique.text}\" is not allowed on its target type", RulesetErrorSeverity.Warning)) - - val typeComplianceErrors = getComplianceErrors(unique) - for (complianceError in typeComplianceErrors) { - if (complianceError.errorSeverity <= severityToReport) - rulesetErrors.add(RulesetError("$prefix unique \"${unique.text}\" contains parameter ${complianceError.parameterName}," + - " which does not fit parameter type" + - " ${complianceError.acceptableParameterTypes.joinToString(" or ") { it.parameterName }} !", - complianceError.errorSeverity.getRulesetErrorSeverity(severityToReport) - )) - } - - for (conditional in unique.conditionals) { - if (conditional.type == null) { - rulesetErrors.add( - "$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + - " which is of an unknown type!", - RulesetErrorSeverity.Warning - ) - } else { - if (conditional.type.targetTypes.none { it.modifierType != UniqueTarget.ModifierType.None }) - rulesetErrors.add("$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + - " which is a Unique type not allowed as conditional or trigger.", - RulesetErrorSeverity.Warning) - - if (conditional.type.targetTypes.contains(UniqueTarget.UnitActionModifier) - && unique.type.targetTypes.none { UniqueTarget.UnitAction.canAcceptUniqueTarget(it) }) - rulesetErrors.add("$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + - " which as a UnitActionModifier is only allowed on UnitAciton uniques.", - RulesetErrorSeverity.Warning) - - val conditionalComplianceErrors = - getComplianceErrors(conditional) - for (complianceError in conditionalComplianceErrors) { - if (complianceError.errorSeverity == severityToReport) - rulesetErrors.add(RulesetError( "$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"." + - " This contains the parameter ${complianceError.parameterName} which does not fit parameter type" + - " ${complianceError.acceptableParameterTypes.joinToString(" or ") { it.parameterName }} !", - complianceError.errorSeverity.getRulesetErrorSeverity(severityToReport) - )) - } - } - } - - - if (severityToReport != UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific) - // If we don't filter these messages will be listed twice as this function is called twice on most objects - // The tests are RulesetInvariant in nature, but RulesetSpecific is called for _all_ objects, invariant is not. - return rulesetErrors - - - val deprecationAnnotation = unique.getDeprecationAnnotation() - if (deprecationAnnotation != null) { - val replacementUniqueText = unique.getReplacementText(ruleset) - val deprecationText = - "$prefix unique \"${unique.text}\" is deprecated ${deprecationAnnotation.message}," + - if (deprecationAnnotation.replaceWith.expression != "") " replace with \"${replacementUniqueText}\"" else "" - val severity = if (deprecationAnnotation.level == DeprecationLevel.WARNING) - RulesetErrorSeverity.WarningOptionsOnly // Not user-visible - else RulesetErrorSeverity.Warning // User visible - - rulesetErrors.add(deprecationText, severity) - } - - return rulesetErrors - } - - /** Maps uncompliant parameters to their required types */ - private fun getComplianceErrors( - unique: Unique, - ): List { - if (unique.type==null) return emptyList() - val errorList = ArrayList() - for ((index, param) in unique.params.withIndex()) { - val acceptableParamTypes = unique.type.parameterTypeMap[index] - val errorTypesForAcceptableParameters = - acceptableParamTypes.map { getParamTypeErrorSeverityCached(it, param) } - if (errorTypesForAcceptableParameters.any { it == null }) continue // This matches one of the types! - val leastSevereWarning = - errorTypesForAcceptableParameters.minByOrNull { it!!.ordinal }!! - errorList += UniqueComplianceError(param, acceptableParamTypes, leastSevereWarning) - } - return errorList - } - - private val paramTypeErrorSeverityCache = HashMap>() - private fun getParamTypeErrorSeverityCached(uniqueParameterType: UniqueParameterType, param:String): UniqueType.UniqueComplianceErrorSeverity? { - if (!paramTypeErrorSeverityCache.containsKey(uniqueParameterType)) - paramTypeErrorSeverityCache[uniqueParameterType] = hashMapOf() - val uniqueParamCache = paramTypeErrorSeverityCache[uniqueParameterType]!! - - if (uniqueParamCache.containsKey(param)) return uniqueParamCache[param] - - val severity = uniqueParameterType.getErrorSeverity(param, ruleset) - uniqueParamCache[param] = severity - return severity - } - - private fun checkUntypedUnique(unique: Unique, tryFixUnknownUniques: Boolean, prefix: String ): List { - // Malformed conditional is always bad - if (unique.text.count { it == '<' } != unique.text.count { it == '>' }) - return listOf(RulesetError( - "$prefix unique \"${unique.text}\" contains mismatched conditional braces!", - RulesetErrorSeverity.Warning)) - - // Support purely filtering Uniques without actual implementation - if (isFilteringUniqueAllowed(unique)) return emptyList() - if (tryFixUnknownUniques) { - val fixes = tryFixUnknownUnique(unique, prefix) - if (fixes.isNotEmpty()) return fixes - } - - return listOf(RulesetError( - "$prefix unique \"${unique.text}\" not found in Unciv's unique types.", - RulesetErrorSeverity.OK)) - } - - private fun isFilteringUniqueAllowed(unique: Unique): Boolean { - // Isolate this decision, to allow easy change of approach - // This says: Must have no conditionals or parameters, and is contained in GlobalUniques - if (unique.conditionals.isNotEmpty() || unique.params.isNotEmpty()) return false - return unique.text in ruleset.globalUniques.uniqueMap - } - - private fun tryFixUnknownUnique(unique: Unique, prefix: String): List { - val similarUniques = UniqueType.values().filter { - getRelativeTextDistance( - it.placeholderText, - unique.placeholderText - ) <= RulesetCache.uniqueMisspellingThreshold - } - val equalUniques = - similarUniques.filter { it.placeholderText == unique.placeholderText } - return when { - // This should only ever happen if a bug is or has been introduced that prevents Unique.type from being set for a valid UniqueType, I think.\ - equalUniques.isNotEmpty() -> listOf(RulesetError( - "$prefix unique \"${unique.text}\" looks like it should be fine, but for some reason isn't recognized.", - RulesetErrorSeverity.OK)) - - similarUniques.isNotEmpty() -> { - val text = - "$prefix unique \"${unique.text}\" looks like it may be a misspelling of:\n" + - similarUniques.joinToString("\n") { uniqueType -> - var text = "\"${uniqueType.text}" - if (unique.conditionals.isNotEmpty()) - text += " " + unique.conditionals.joinToString(" ") { "<${it.text}>" } - text += "\"" - if (uniqueType.getDeprecationAnnotation() != null) text += " (Deprecated)" - return@joinToString text - }.prependIndent("\t") - listOf(RulesetError(text, RulesetErrorSeverity.OK)) - } - else -> emptyList() - } - } } diff --git a/core/src/com/unciv/models/ruleset/validation/UniqueValidator.kt b/core/src/com/unciv/models/ruleset/validation/UniqueValidator.kt new file mode 100644 index 0000000000..859ba66058 --- /dev/null +++ b/core/src/com/unciv/models/ruleset/validation/UniqueValidator.kt @@ -0,0 +1,201 @@ +package com.unciv.models.ruleset.validation + +import com.unciv.models.ruleset.IRulesetObject +import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.ruleset.unique.IHasUniques +import com.unciv.models.ruleset.unique.Unique +import com.unciv.models.ruleset.unique.UniqueComplianceError +import com.unciv.models.ruleset.unique.UniqueParameterType +import com.unciv.models.ruleset.unique.UniqueTarget +import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.models.stats.INamed + +class UniqueValidator(val ruleset: Ruleset) { + + fun checkUniques( + uniqueContainer: IHasUniques, + lines: RulesetErrorList, + severityToReport: UniqueType.UniqueComplianceErrorSeverity, + tryFixUnknownUniques: Boolean + ) { + for (unique in uniqueContainer.uniqueObjects) { + val errors = checkUnique( + unique, + tryFixUnknownUniques, + uniqueContainer as? INamed, + severityToReport + ) + lines.addAll(errors) + } + } + + fun checkUnique( + unique: Unique, + tryFixUnknownUniques: Boolean, + namedObj: INamed?, + severityToReport: UniqueType.UniqueComplianceErrorSeverity + ): List { + val prefix by lazy { (if (namedObj is IRulesetObject) "${namedObj.originRuleset}: " else "") + + (if (namedObj == null) "The" else "${namedObj.name}'s") } + if (unique.type == null) return checkUntypedUnique(unique, tryFixUnknownUniques, prefix) + + val rulesetErrors = RulesetErrorList() + + if (namedObj is IHasUniques && !unique.type.canAcceptUniqueTarget(namedObj.getUniqueTarget())) + rulesetErrors.add(RulesetError("$prefix unique \"${unique.text}\" is not allowed on its target type", RulesetErrorSeverity.Warning)) + + val typeComplianceErrors = getComplianceErrors(unique) + for (complianceError in typeComplianceErrors) { + if (complianceError.errorSeverity <= severityToReport) + rulesetErrors.add(RulesetError("$prefix unique \"${unique.text}\" contains parameter ${complianceError.parameterName}," + + " which does not fit parameter type" + + " ${complianceError.acceptableParameterTypes.joinToString(" or ") { it.parameterName }} !", + complianceError.errorSeverity.getRulesetErrorSeverity(severityToReport) + )) + } + + for (conditional in unique.conditionals) { + if (conditional.type == null) { + rulesetErrors.add( + "$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + + " which is of an unknown type!", + RulesetErrorSeverity.Warning + ) + } else { + if (conditional.type.targetTypes.none { it.modifierType != UniqueTarget.ModifierType.None }) + rulesetErrors.add("$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + + " which is a Unique type not allowed as conditional or trigger.", + RulesetErrorSeverity.Warning) + + if (conditional.type.targetTypes.contains(UniqueTarget.UnitActionModifier) + && unique.type.targetTypes.none { UniqueTarget.UnitAction.canAcceptUniqueTarget(it) }) + rulesetErrors.add("$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"," + + " which as a UnitActionModifier is only allowed on UnitAciton uniques.", + RulesetErrorSeverity.Warning) + + val conditionalComplianceErrors = + getComplianceErrors(conditional) + for (complianceError in conditionalComplianceErrors) { + if (complianceError.errorSeverity == severityToReport) + rulesetErrors.add(RulesetError( "$prefix unique \"${unique.text}\" contains the conditional \"${conditional.text}\"." + + " This contains the parameter ${complianceError.parameterName} which does not fit parameter type" + + " ${complianceError.acceptableParameterTypes.joinToString(" or ") { it.parameterName }} !", + complianceError.errorSeverity.getRulesetErrorSeverity(severityToReport) + )) + } + } + } + + + if (severityToReport != UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific) + // If we don't filter these messages will be listed twice as this function is called twice on most objects + // The tests are RulesetInvariant in nature, but RulesetSpecific is called for _all_ objects, invariant is not. + return rulesetErrors + + + val deprecationAnnotation = unique.getDeprecationAnnotation() + if (deprecationAnnotation != null) { + val replacementUniqueText = unique.getReplacementText(ruleset) + val deprecationText = + "$prefix unique \"${unique.text}\" is deprecated ${deprecationAnnotation.message}," + + if (deprecationAnnotation.replaceWith.expression != "") " replace with \"${replacementUniqueText}\"" else "" + val severity = if (deprecationAnnotation.level == DeprecationLevel.WARNING) + RulesetErrorSeverity.WarningOptionsOnly // Not user-visible + else RulesetErrorSeverity.Warning // User visible + + rulesetErrors.add(deprecationText, severity) + } + + return rulesetErrors + } + + /** Maps uncompliant parameters to their required types */ + private fun getComplianceErrors( + unique: Unique, + ): List { + if (unique.type==null) return emptyList() + val errorList = ArrayList() + for ((index, param) in unique.params.withIndex()) { + val acceptableParamTypes = unique.type.parameterTypeMap[index] + val errorTypesForAcceptableParameters = + acceptableParamTypes.map { getParamTypeErrorSeverityCached(it, param) } + if (errorTypesForAcceptableParameters.any { it == null }) continue // This matches one of the types! + val leastSevereWarning = + errorTypesForAcceptableParameters.minByOrNull { it!!.ordinal }!! + errorList += UniqueComplianceError(param, acceptableParamTypes, leastSevereWarning) + } + return errorList + } + + private val paramTypeErrorSeverityCache = HashMap>() + private fun getParamTypeErrorSeverityCached(uniqueParameterType: UniqueParameterType, param:String): UniqueType.UniqueComplianceErrorSeverity? { + if (!paramTypeErrorSeverityCache.containsKey(uniqueParameterType)) + paramTypeErrorSeverityCache[uniqueParameterType] = hashMapOf() + val uniqueParamCache = paramTypeErrorSeverityCache[uniqueParameterType]!! + + if (uniqueParamCache.containsKey(param)) return uniqueParamCache[param] + + val severity = uniqueParameterType.getErrorSeverity(param, ruleset) + uniqueParamCache[param] = severity + return severity + } + + private fun checkUntypedUnique(unique: Unique, tryFixUnknownUniques: Boolean, prefix: String ): List { + // Malformed conditional is always bad + if (unique.text.count { it == '<' } != unique.text.count { it == '>' }) + return listOf(RulesetError( + "$prefix unique \"${unique.text}\" contains mismatched conditional braces!", + RulesetErrorSeverity.Warning)) + + // Support purely filtering Uniques without actual implementation + if (isFilteringUniqueAllowed(unique)) return emptyList() + if (tryFixUnknownUniques) { + val fixes = tryFixUnknownUnique(unique, prefix) + if (fixes.isNotEmpty()) return fixes + } + + return listOf(RulesetError( + "$prefix unique \"${unique.text}\" not found in Unciv's unique types.", + RulesetErrorSeverity.OK)) + } + + private fun isFilteringUniqueAllowed(unique: Unique): Boolean { + // Isolate this decision, to allow easy change of approach + // This says: Must have no conditionals or parameters, and is contained in GlobalUniques + if (unique.conditionals.isNotEmpty() || unique.params.isNotEmpty()) return false + return unique.text in ruleset.globalUniques.uniqueMap + } + + private fun tryFixUnknownUnique(unique: Unique, prefix: String): List { + val similarUniques = UniqueType.values().filter { + getRelativeTextDistance( + it.placeholderText, + unique.placeholderText + ) <= RulesetCache.uniqueMisspellingThreshold + } + val equalUniques = + similarUniques.filter { it.placeholderText == unique.placeholderText } + return when { + // This should only ever happen if a bug is or has been introduced that prevents Unique.type from being set for a valid UniqueType, I think.\ + equalUniques.isNotEmpty() -> listOf(RulesetError( + "$prefix unique \"${unique.text}\" looks like it should be fine, but for some reason isn't recognized.", + RulesetErrorSeverity.OK)) + + similarUniques.isNotEmpty() -> { + val text = + "$prefix unique \"${unique.text}\" looks like it may be a misspelling of:\n" + + similarUniques.joinToString("\n") { uniqueType -> + var text = "\"${uniqueType.text}" + if (unique.conditionals.isNotEmpty()) + text += " " + unique.conditionals.joinToString(" ") { "<${it.text}>" } + text += "\"" + if (uniqueType.getDeprecationAnnotation() != null) text += " (Deprecated)" + return@joinToString text + }.prependIndent("\t") + listOf(RulesetError(text, RulesetErrorSeverity.OK)) + } + else -> emptyList() + } + } +}