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
This commit is contained in:
SomeTroglodyte
2023-07-30 16:39:28 +02:00
committed by GitHub
parent 443bf3afdb
commit b838d8ec5a
10 changed files with 139 additions and 78 deletions

View File

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

View File

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

View File

@ -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,19 +870,26 @@ 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.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)
@ -879,43 +899,60 @@ object Battle {
}
if (tile.getUnpillagedRoad() != RoadStatus.None)
tile.setPillaged()
if (tile.isLand && !tile.isImpassible() && !tile.isCityCenter()) {
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)) {
if (Random.Default.nextFloat() >= unique.params[0].toFloat() / 100f) continue
val chance = unique.params[0].toFloat() / 100f
if (!(chance > 0f && isGroundZero) && Random.Default.nextFloat() >= chance) continue
tile.removeTerrainFeature(terrainFeature.name)
if (!tile.terrainFeatures.contains("Fallout"))
tile.addTerrainFeature("Fallout")
applyPillageAndFallout()
}
}
} else if (Random.Default.nextFloat() < 0.5f && !tile.terrainFeatures.contains("Fallout")) {
tile.addTerrainFeature("Fallout")
}
} 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 *
// 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(40)) / 100f
2 -> (60 + Random.Default.nextInt(20)) / 100f
else -> 1f
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
}
for (unique in targetedCity.getMatchingUniques(UniqueType.PopulationLossFromNukes)) {
if (!targetedCity.matchesFilter(unique.params[1])) continue
populationLoss *= unique.params[0].toPercent()
).toInt().coerceAtMost(targetedCity.population.population - 1)
targetedCity.population.addPopulation(-populationLoss)
}
targetedCity.population.addPopulation(-populationLoss.toInt())
if (targetedCity.population.population < 1) targetedCity.population.setPopulation(1)
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()
}
return modifier
}
// Should draw an Interception if available on the tile from any Civ

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AttackableTile> =
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 }

View File

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

View File

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