From b838d8ec5a71f31ded33daca513ed59046f89010 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sun, 30 Jul 2023 16:39:28 +0200 Subject: [PATCH] Overhaul NUKE code to behave closer to original (#9797) * Overhaul NUKE code to behave closer to original * Separate garrison protection of Bomb Shelter to its own Unique * Reduce code duplication: getNukeBlastRadius * Disallow nuking unknown civs * Don't show Nuke attack table when the Nuke has just been selected * World map display of nuke blast radius and friendly fire --- .../jsons/Civ V - Gods & Kings/Buildings.json | 2 +- .../automation/unit/SpecificUnitAutomation.kt | 3 +- core/src/com/unciv/logic/battle/Battle.kt | 169 +++++++++++------- .../com/unciv/logic/map/mapunit/MapUnit.kt | 4 + core/src/com/unciv/logic/map/tile/Tile.kt | 5 +- .../unciv/models/ruleset/unique/UniqueType.kt | 1 + .../tilegroups/layers/TileLayerMisc.kt | 8 + .../ui/screens/worldscreen/WorldMapHolder.kt | 15 +- .../worldscreen/bottombar/BattleTable.kt | 5 +- docs/Modders/uniques.md | 5 + 10 files changed, 139 insertions(+), 78 deletions(-) diff --git a/android/assets/jsons/Civ V - Gods & Kings/Buildings.json b/android/assets/jsons/Civ V - Gods & Kings/Buildings.json index 38aee87641..e36c5edf02 100644 --- a/android/assets/jsons/Civ V - Gods & Kings/Buildings.json +++ b/android/assets/jsons/Civ V - Gods & Kings/Buildings.json @@ -1100,7 +1100,7 @@ "cost": 300, "maintenance": 1, "requiredTech": "Telecommunications", - "uniques": ["Population loss from nuclear attacks [-75]% [in this city]"] + "uniques": ["Population loss from nuclear attacks [-75]% [in this city]","Damage to garrison from nuclear attacks [-75]% [in this city]"] }, { "name": "Hubble Space Telescope", diff --git a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt index 02e7254b0a..6fa2adae72 100644 --- a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt @@ -500,8 +500,7 @@ object SpecificUnitAutomation { && tile.getOwner()!!.isAtWarWith(unit.civ) && tile.getCity()!!.health > tile.getCity()!!.getMaxHealth() / 2 && Battle.mayUseNuke(MapUnitCombatant(unit), tile)) { - val blastRadius = unit.getMatchingUniques(UniqueType.BlastRadius) - .firstOrNull()?.params?.get(0)?.toInt() ?: 2 + val blastRadius = unit.getNukeBlastRadius() val tilesInBlastRadius = tile.getTilesInDistance(blastRadius) val civsInBlastRadius = tilesInBlastRadius.mapNotNull { it.getOwner() } + tilesInBlastRadius.mapNotNull { it.getFirstUnit()?.civ } diff --git a/core/src/com/unciv/logic/battle/Battle.kt b/core/src/com/unciv/logic/battle/Battle.kt index 321195e8a5..2c0180d422 100644 --- a/core/src/com/unciv/logic/battle/Battle.kt +++ b/core/src/com/unciv/logic/battle/Battle.kt @@ -5,6 +5,7 @@ import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.automation.civilization.NextTurnAutomation import com.unciv.logic.automation.unit.AttackableTile +import com.unciv.logic.automation.unit.SpecificUnitAutomation import com.unciv.logic.city.City import com.unciv.logic.civilization.AlertType import com.unciv.logic.civilization.Civilization @@ -27,9 +28,11 @@ import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.stats.Stat import com.unciv.models.stats.Stats import com.unciv.ui.components.extensions.toPercent +import com.unciv.ui.screens.worldscreen.bottombar.BattleTable import com.unciv.utils.debug import kotlin.math.max import kotlin.math.min +import kotlin.math.ulp import kotlin.random.Random /** @@ -742,25 +745,34 @@ object Battle { } } + /** + * Checks whether [nuke] is allowed to nuke [targetTile] + * - Not if we would need to declare war on someone we can't. + * - Disallow nuking the tile the nuke is in, as per Civ5 (but not nuking your own tiles/units otherwise) + * + * Both [BattleTable.simulateNuke] and [SpecificUnitAutomation.automateNukes] check range, so that check is omitted here. + */ fun mayUseNuke(nuke: MapUnitCombatant, targetTile: Tile): Boolean { - val blastRadius = - if (!nuke.hasUnique(UniqueType.BlastRadius)) 2 - // Don't check conditionals as these are not supported - else nuke.unit.getMatchingUniques(UniqueType.BlastRadius).first().params[0].toInt() + if (nuke.getTile() == targetTile) return false var canNuke = true val attackerCiv = nuke.getCivInfo() - for (tile in targetTile.getTilesInDistance(blastRadius)) { - val defendingTileCiv = tile.getCity()?.civ - if (defendingTileCiv != null && attackerCiv.knows(defendingTileCiv)) { - canNuke = canNuke && attackerCiv.getDiplomacyManager(defendingTileCiv).canAttack() - } + fun checkDefenderCiv(defenderCiv: Civilization?) { + if (defenderCiv == null) return + // Allow nuking yourself! (Civ5 source: CvUnit::isNukeVictim) + if (defenderCiv == attackerCiv || defenderCiv.isDefeated()) return + // Gleaned from Civ5 source - this disallows nuking unknown civs even in invisible tiles + // https://github.com/Gedemon/Civ5-DLL/blob/master/CvGameCoreDLL_Expansion1/CvUnit.cpp#L5056 + // https://github.com/Gedemon/Civ5-DLL/blob/master/CvGameCoreDLL_Expansion1/CvTeam.cpp#L986 + if (attackerCiv.knows(defenderCiv) && attackerCiv.getDiplomacyManager(defenderCiv).canAttack()) + return + canNuke = false + } - val defender = getMapCombatantOfTile(tile) ?: continue - val defendingUnitCiv = defender.getCivInfo() - if (attackerCiv.knows(defendingUnitCiv)) { - canNuke = canNuke && attackerCiv.getDiplomacyManager(defendingUnitCiv).canAttack() - } + val blastRadius = nuke.unit.getNukeBlastRadius() + for (tile in targetTile.getTilesInDistance(blastRadius)) { + checkDefenderCiv(tile.getOwner()) + checkDefenderCiv(getMapCombatantOfTile(tile)?.getCivInfo()) } return canNuke } @@ -778,7 +790,7 @@ object Battle { } } - val strength = attacker.unit.getMatchingUniques(UniqueType.NuclearWeapon) + val nukeStrength = attacker.unit.getMatchingUniques(UniqueType.NuclearWeapon) .firstOrNull()?.params?.get(0)?.toInt() ?: return val blastRadius = attacker.unit.getMatchingUniques(UniqueType.BlastRadius) @@ -794,10 +806,10 @@ object Battle { } // Declare war on all potentially hit units. They'll try to intercept the nuke before it drops - for(civWhoseUnitWasAttacked in hitTiles + for (civWhoseUnitWasAttacked in hitTiles .flatMap { it.getUnits() } .map { it.civ }.distinct() - .filter{it != attackingCiv}) { + .filter { it != attackingCiv }) { tryDeclareWar(civWhoseUnitWasAttacked) if (attacker.unit.baseUnit.isAirUnit() && !attacker.isDefeated()) { tryInterceptAirAttack(attacker, targetTile, civWhoseUnitWasAttacked, null) @@ -807,17 +819,9 @@ object Battle { attacker.unit.attacksSinceTurnStart.add(Vector2(targetTile.position)) - // Destroy units on the target tile - // Needs the toList() because if we're destroying the units, they're no longer part of the sequence - for (defender in targetTile.getUnits().filter { it != attacker.unit }.toList()) { - defender.destroy() - postBattleNotifications(attacker, MapUnitCombatant(defender), defender.getTile()) - destroyIfDefeated(defender.civ, attacker.getCivInfo()) - } - for (tile in hitTiles) { // Handle complicated effects - doNukeExplosionForTile(attacker, tile, strength) + doNukeExplosionForTile(attacker, tile, nukeStrength, targetTile == tile) } // Instead of postBattleAction() just destroy the unit, all other functions are not relevant @@ -834,7 +838,12 @@ object Battle { } } - private fun doNukeExplosionForTile(attacker: MapUnitCombatant, tile: Tile, nukeStrength: Int) { + private fun doNukeExplosionForTile( + attacker: MapUnitCombatant, + tile: Tile, + nukeStrength: Int, + isGroundZero: Boolean + ) { // https://forums.civfanatics.com/resources/unit-guide-modern-future-units-g-k.25628/ // https://www.carlsguides.com/strategy/civilization5/units/aircraft-nukes.ph // Testing done by Ravignir @@ -845,11 +854,15 @@ object Battle { for (resource in attacker.unit.baseUnit.getResourceRequirementsPerTurn().keys) { if (civResources[resource]!! < 0 && !attacker.getCivInfo().isBarbarian()) damageModifierFromMissingResource *= 0.5f // I could not find a source for this number, but this felt about right + // - Original Civ5 does *not* reduce damage from missing resource, from source inspection } + var buildingModifier = 1f // Strange, but in Civ5 a bunker mitigates damage to garrison, even if the city is destroyed by the nuke + // Damage city and reduce its population val city = tile.getCity() if (city != null && tile.position == city.location) { + buildingModifier = city.getAggregateModifier(UniqueType.GarrisonDamageFromNukes) doNukeExplosionDamageToCity(city, nukeStrength, damageModifierFromMissingResource) postBattleNotifications(attacker, CityCombatant(city), city.getCenterTile()) destroyIfDefeated(city.civ, attacker.getCivInfo()) @@ -857,65 +870,89 @@ object Battle { // Damage and/or destroy units on the tile for (unit in tile.getUnits().toList()) { // toList so if it's destroyed there's no concurrent modification + val damage = (when { + isGroundZero || nukeStrength >= 2 -> 100 + // The following constants are NUKE_UNIT_DAMAGE_BASE / NUKE_UNIT_DAMAGE_RAND_1 / NUKE_UNIT_DAMAGE_RAND_2 in Civ5 + nukeStrength == 1 -> 30 + Random.Default.nextInt(40) + Random.Default.nextInt(40) + // Level 0 does not exist in Civ5 (it treats units same as level 2) + else -> 20 + Random.Default.nextInt(30) + } * buildingModifier * damageModifierFromMissingResource + 1f.ulp).toInt() val defender = MapUnitCombatant(unit) - if (defender.unit.isCivilian() || nukeStrength >= 2) { - unit.destroy() - } else if (nukeStrength == 1) { - defender.takeDamage(((40 + Random.Default.nextInt(60)) * damageModifierFromMissingResource).toInt()) - } else if (nukeStrength == 0) { - defender.takeDamage(((20 + Random.Default.nextInt(30)) * damageModifierFromMissingResource).toInt()) + if (unit.isCivilian()) { + if (unit.health - damage <= 40) unit.destroy() // Civ5: NUKE_NON_COMBAT_DEATH_THRESHOLD = 60 + } else { + defender.takeDamage(damage) } postBattleNotifications(attacker, defender, defender.getTile()) destroyIfDefeated(defender.getCivInfo(), attacker.getCivInfo()) } // Pillage improvements, pillage roads, add fallout - if (tile.getUnpillagedImprovement() != null && !tile.getTileImprovement()!!.hasUnique(UniqueType.Irremovable)) { - if (tile.getTileImprovement()!!.hasUnique(UniqueType.Unpillagable)) { - tile.changeImprovement(null) - } else { - tile.setPillaged() - } - } - if (tile.getUnpillagedRoad() != RoadStatus.None) - tile.setPillaged() - if (tile.isLand && !tile.isImpassible() && !tile.isCityCenter()) { - if (tile.terrainHasUnique(UniqueType.DestroyableByNukesChance)) { - for (terrainFeature in tile.terrainFeatureObjects) { - for (unique in terrainFeature.getMatchingUniques(UniqueType.DestroyableByNukesChance)) { - if (Random.Default.nextFloat() >= unique.params[0].toFloat() / 100f) continue - tile.removeTerrainFeature(terrainFeature.name) - if (!tile.terrainFeatures.contains("Fallout")) - tile.addTerrainFeature("Fallout") - } + if (tile.isCityCenter()) return // Never touch city centers - if they survived + fun applyPillageAndFallout() { + if (tile.getUnpillagedImprovement() != null && !tile.getTileImprovement()!!.hasUnique(UniqueType.Irremovable)) { + if (tile.getTileImprovement()!!.hasUnique(UniqueType.Unpillagable)) { + tile.changeImprovement(null) + } else { + tile.setPillaged() } - } else if (Random.Default.nextFloat() < 0.5f && !tile.terrainFeatures.contains("Fallout")) { - tile.addTerrainFeature("Fallout") } + if (tile.getUnpillagedRoad() != RoadStatus.None) + tile.setPillaged() + if (tile.isWater || tile.isImpassible() || tile.terrainFeatures.contains("Fallout")) return + tile.addTerrainFeature("Fallout") + } + + if (tile.terrainHasUnique(UniqueType.DestroyableByNukesChance)) { + // Note: Safe from concurrent modification exceptions only because removeTerrainFeature + // *replaces* terrainFeatureObjects and the loop will continue on the old one + for (terrainFeature in tile.terrainFeatureObjects) { + for (unique in terrainFeature.getMatchingUniques(UniqueType.DestroyableByNukesChance)) { + val chance = unique.params[0].toFloat() / 100f + if (!(chance > 0f && isGroundZero) && Random.Default.nextFloat() >= chance) continue + tile.removeTerrainFeature(terrainFeature.name) + applyPillageAndFallout() + } + } + } else if (isGroundZero || Random.Default.nextFloat() < 0.5f) { // Civ5: NUKE_FALLOUT_PROB + applyPillageAndFallout() } } + /** @return the "protection" modifier from buildings (Bomb Shelter, UniqueType.PopulationLossFromNukes) */ private fun doNukeExplosionDamageToCity(targetedCity: City, nukeStrength: Int, damageModifierFromMissingResource: Float) { - if (nukeStrength > 1 && targetedCity.population.population < 5 && targetedCity.canBeDestroyed(true)) { + // Original Capitals must be protected, `canBeDestroyed` is responsible for that check. + // The `justCaptured = true` parameter is what allows other Capitals to suffer normally. + if ((nukeStrength > 2 || nukeStrength > 1 && targetedCity.population.population < 5) + && targetedCity.canBeDestroyed(true)) { targetedCity.destroyCity() return } + val cityCombatant = CityCombatant(targetedCity) cityCombatant.takeDamage((cityCombatant.getHealth() * 0.5f * damageModifierFromMissingResource).toInt()) - var populationLoss = targetedCity.population.population * - when (nukeStrength) { - 0 -> 0f - 1 -> (30 + Random.Default.nextInt(40)) / 100f - 2 -> (60 + Random.Default.nextInt(20)) / 100f - else -> 1f - } - for (unique in targetedCity.getMatchingUniques(UniqueType.PopulationLossFromNukes)) { - if (!targetedCity.matchesFilter(unique.params[1])) continue - populationLoss *= unique.params[0].toPercent() + // Difference to original: Civ5 rounds population loss down twice - before and after bomb shelters + val populationLoss = ( + targetedCity.population.population * + targetedCity.getAggregateModifier(UniqueType.PopulationLossFromNukes) * + when (nukeStrength) { + 0 -> 0f + 1 -> (30 + Random.Default.nextInt(20) + Random.Default.nextInt(20)) / 100f + 2 -> (60 + Random.Default.nextInt(10) + Random.Default.nextInt(10)) / 100f + else -> 1f // hypothetical nukeStrength 3 -> always to 1 pop + } + ).toInt().coerceAtMost(targetedCity.population.population - 1) + targetedCity.population.addPopulation(-populationLoss) + } + + private fun City.getAggregateModifier(uniqueType: UniqueType): Float { + var modifier = 1f + for (unique in getMatchingUniques(uniqueType)) { + if (!matchesFilter(unique.params[1])) continue + modifier *= unique.params[0].toPercent() } - targetedCity.population.addPopulation(-populationLoss.toInt()) - if (targetedCity.population.population < 1) targetedCity.population.setPopulation(1) + return modifier } // Should draw an Interception if available on the tile from any Civ diff --git a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt index f7b9e36bbf..4e99ba1ae0 100644 --- a/core/src/com/unciv/logic/map/mapunit/MapUnit.kt +++ b/core/src/com/unciv/logic/map/mapunit/MapUnit.kt @@ -789,6 +789,10 @@ class MapUnit : IsPartOfGameInfoSerialization { return true } + /** Gets a Nuke's blast radius from the BlastRadius unique, defaulting to 2. No check whether the unit actually is a Nuke. */ + fun getNukeBlastRadius() = getMatchingUniques(UniqueType.BlastRadius) + // Don't check conditionals as these are not supported + .firstOrNull()?.params?.get(0)?.toInt() ?: 2 private fun isAlly(otherCiv: Civilization): Boolean { return otherCiv == civ diff --git a/core/src/com/unciv/logic/map/tile/Tile.kt b/core/src/com/unciv/logic/map/tile/Tile.kt index 0a266e3bfd..aa40da338c 100644 --- a/core/src/com/unciv/logic/map/tile/Tile.kt +++ b/core/src/com/unciv/logic/map/tile/Tile.kt @@ -377,10 +377,7 @@ open class Tile : IsPartOfGameInfoSerialization { fun getBaseTerrain(): Terrain = baseTerrainObject - fun getOwner(): Civilization? { - val containingCity = getCity() ?: return null - return containingCity.civ - } + fun getOwner(): Civilization? = getCity()?.civ fun getRoadOwner(): Civilization? { return if (roadOwner != "") diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index ab9c9ab1e5..f9f1f76acd 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -241,6 +241,7 @@ enum class UniqueType(val text: String, vararg targets: UniqueTarget, val flags: GoldenAgeLength("[relativeAmount]% Golden Age length", UniqueTarget.Global), PopulationLossFromNukes("Population loss from nuclear attacks [relativeAmount]% [cityFilter]", UniqueTarget.Global), + GarrisonDamageFromNukes("Damage to garrison from nuclear attacks [relativeAmount]% [cityFilter]", UniqueTarget.Global), SpawnRebels("Rebel units may spawn", UniqueTarget.Global), diff --git a/core/src/com/unciv/ui/components/tilegroups/layers/TileLayerMisc.kt b/core/src/com/unciv/ui/components/tilegroups/layers/TileLayerMisc.kt index d7c83664f8..02da9779f6 100644 --- a/core/src/com/unciv/ui/components/tilegroups/layers/TileLayerMisc.kt +++ b/core/src/com/unciv/ui/components/tilegroups/layers/TileLayerMisc.kt @@ -325,12 +325,20 @@ class TileLayerMisc(tileGroup: TileGroup, size: Float) : TileLayer(tileGroup, si determineVisibility() } + /** Activates a colored semitransparent overlay. [color] is cloned, brightened by 0.3f and an alpha of 0.4f applied. */ fun overlayTerrain(color: Color) { terrainOverlay.color = color.cpy().lerp(Color.WHITE, 0.3f).apply { a = 0.4f } terrainOverlay.isVisible = true determineVisibility() } + /** Activates a colored semitransparent overlay. [color] is cloned and [alpha] applied. No brightening unlike the overload without explicit alpha! */ + fun overlayTerrain(color: Color, alpha: Float) { + terrainOverlay.color = color.cpy().apply { a = alpha } + terrainOverlay.isVisible = true + determineVisibility() + } + fun hideTerrainOverlay(){ terrainOverlay.isVisible = false determineVisibility() diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt index b18e647607..66d601e6eb 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt @@ -637,6 +637,9 @@ class WorldMapHolder( val isAirUnit = unit.baseUnit.movesLikeAirUnits() val moveTileOverlayColor = if (unit.isPreparingParadrop()) Color.BLUE else Color.WHITE val tilesInMoveRange = unit.movement.getReachableTilesInCurrentTurn() + // Prepare special Nuke blast radius display + val nukeBlastRadius = if (unit.baseUnit.isNuclearWeapon() && selectedTile != null && selectedTile != unit.getTile()) + unit.getNukeBlastRadius() else -1 // Highlight tiles within movement range for (tile in tilesInMoveRange) { @@ -644,7 +647,10 @@ class WorldMapHolder( // Air-units have additional highlights if (isAirUnit && !unit.isPreparingAirSweep()) { - if (tile.aerialDistanceTo(unit.getTile()) <= unit.getRange()) { + if (nukeBlastRadius >= 0 && tile.aerialDistanceTo(selectedTile!!) <= nukeBlastRadius) { + // The tile is within the nuke blast radius + group.layerMisc.overlayTerrain(Color.FIREBRICK, 0.6f) + } else if (tile.aerialDistanceTo(unit.getTile()) <= unit.getRange()) { // The tile is within attack range group.layerMisc.overlayTerrain(Color.RED) } else if (tile.isExplored(worldScreen.viewingCiv) && tile.aerialDistanceTo(unit.getTile()) <= unit.getRange()*2) { @@ -687,7 +693,12 @@ class WorldMapHolder( if (unit.isMilitary()) { val attackableTiles: List = - BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) + if (nukeBlastRadius >= 0) + selectedTile!!.getTilesInDistance(nukeBlastRadius) + .filter { it.getFirstUnit() != null } + .map { AttackableTile(unit.getTile(), it, 1f, null) } + .toList() + else BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) .filter { it.tileToAttack.isVisible(unit.civ) } .distinctBy { it.tileToAttack } diff --git a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt index ef9ce0cff7..04d9858b54 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt @@ -63,6 +63,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) { val selectedTile = worldScreen.mapHolder.selectedTile ?: return hide() // no selected tile + if (selectedTile == attacker.getTile()) return hide() // mayUseNuke would test this again, but not actually seeing the nuke-yourself table just by selecting the nuke is nicer simulateNuke(attacker, selectedTile) } else if (attacker is MapUnitCombatant && attacker.unit.isPreparingAirSweep()) { val selectedTile = worldScreen.mapHolder.selectedTile @@ -305,9 +306,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { val canNuke = Battle.mayUseNuke(attacker, targetTile) - val blastRadius = - if (!attacker.unit.hasUnique(UniqueType.BlastRadius)) 2 - else attacker.unit.getMatchingUniques(UniqueType.BlastRadius).first().params[0].toInt() + val blastRadius = attacker.unit.getNukeBlastRadius() val defenderNameWrapper = Table() for (tile in targetTile.getTilesInDistance(blastRadius)) { diff --git a/docs/Modders/uniques.md b/docs/Modders/uniques.md index 473e70fa5c..146436cb77 100644 --- a/docs/Modders/uniques.md +++ b/docs/Modders/uniques.md @@ -738,6 +738,11 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl Applicable to: Global +??? example "Damage to garrison from nuclear attacks [relativeAmount]% [cityFilter]" + Example: "Damage to garrison from nuclear attacks [+20]% [in all cities]" + + Applicable to: Global + ??? example "Rebel units may spawn" Applicable to: Global