chore: Separated Nuke logic into separate object

This commit is contained in:
Yair Morgenstern 2023-10-03 12:32:35 +03:00
parent 348910dcf7
commit cc6ab7f7d5
5 changed files with 319 additions and 302 deletions

View File

@ -433,7 +433,7 @@ object NextTurnAutomation {
} }
} }
private fun valueCityStateAlliance(civInfo: Civilization, cityState: Civilization): Int { internal fun valueCityStateAlliance(civInfo: Civilization, cityState: Civilization): Int {
var value = 0 var value = 0
if (civInfo.wantsToFocusOn(Victory.Focus.Culture) && cityState.cityStateFunctions.canProvideStat(Stat.Culture)) { if (civInfo.wantsToFocusOn(Victory.Focus.Culture) && cityState.cityStateFunctions.canProvideStat(Stat.Culture)) {
@ -1284,31 +1284,6 @@ object NextTurnAutomation {
diplomacyManager.removeFlag(DiplomacyFlags.SettledCitiesNearUs) diplomacyManager.removeFlag(DiplomacyFlags.SettledCitiesNearUs)
} }
/** Handle decision making after city conquest, namely whether the AI should liberate, puppet,
* or raze a city */
fun onConquerCity(civInfo: Civilization, city: City) {
if (!city.hasDiplomaticMarriage()) {
val foundingCiv = civInfo.gameInfo.getCivilization(city.foundingCiv)
var valueAlliance = valueCityStateAlliance(civInfo, foundingCiv)
if (civInfo.getHappiness() < 0)
valueAlliance -= civInfo.getHappiness() // put extra weight on liberating if unhappy
if (foundingCiv.isCityState() && city.civ != civInfo && foundingCiv != civInfo
&& !civInfo.isAtWarWith(foundingCiv)
&& valueAlliance > 0) {
city.liberateCity(civInfo)
return
}
}
city.puppetCity(civInfo)
if ((city.population.population < 4 || civInfo.isCityState())
&& city.foundingCiv != civInfo.civName && city.canBeDestroyed(justCaptured = true)) {
// raze if attacker is a city state
if (!civInfo.hasUnique(UniqueType.MayNotAnnexCities)) { city.annexCity() }
city.isBeingRazed = true
}
}
fun getMinDistanceBetweenCities(civ1: Civilization, civ2: Civilization): Int { fun getMinDistanceBetweenCities(civ1: Civilization, civ2: Civilization): Int {
return getClosestCities(civ1, civ2)?.aerialDistance ?: Int.MAX_VALUE return getClosestCities(civ1, civ2)?.aerialDistance ?: Int.MAX_VALUE
} }

View File

@ -2,9 +2,9 @@
import com.unciv.Constants import com.unciv.Constants
import com.unciv.logic.automation.Automation import com.unciv.logic.automation.Automation
import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.GreatGeneralImplementation import com.unciv.logic.battle.GreatGeneralImplementation
import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.Nuke
import com.unciv.logic.battle.TargetHelper import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.city.City import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.Civilization
@ -496,7 +496,7 @@ object SpecificUnitAutomation {
} }
} }
if (highestTileNukeValue > 0) { if (highestTileNukeValue > 0) {
Battle.NUKE(MapUnitCombatant(unit), tileToNuke!!) Nuke.NUKE(MapUnitCombatant(unit), tileToNuke!!)
} }
tryRelocateToNearbyAttackableCities(unit) tryRelocateToNearbyAttackableCities(unit)
} }
@ -507,7 +507,7 @@ object SpecificUnitAutomation {
*/ */
fun getNukeLocationValue(nuke: MapUnit, tile: Tile): Int { fun getNukeLocationValue(nuke: MapUnit, tile: Tile): Int {
val civ = nuke.civ val civ = nuke.civ
if (!Battle.mayUseNuke(MapUnitCombatant(nuke), tile)) return Int.MIN_VALUE if (!Nuke.mayUseNuke(MapUnitCombatant(nuke), tile)) return Int.MIN_VALUE
val blastRadius = nuke.getNukeBlastRadius() val blastRadius = nuke.getNukeBlastRadius()
val tilesInBlastRadius = tile.getTilesInDistance(blastRadius) val tilesInBlastRadius = tile.getTilesInDistance(blastRadius)
val civsInBlastRadius = tilesInBlastRadius.mapNotNull { it.getOwner() } + val civsInBlastRadius = tilesInBlastRadius.mapNotNull { it.getOwner() } +

View File

@ -4,11 +4,9 @@ import com.badlogic.gdx.math.Vector2
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.automation.civilization.NextTurnAutomation import com.unciv.logic.automation.civilization.NextTurnAutomation
import com.unciv.logic.automation.unit.SpecificUnitAutomation
import com.unciv.logic.city.City import com.unciv.logic.city.City
import com.unciv.logic.civilization.AlertType import com.unciv.logic.civilization.AlertType
import com.unciv.logic.civilization.Civilization import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.CivilopediaAction
import com.unciv.logic.civilization.LocationAction import com.unciv.logic.civilization.LocationAction
import com.unciv.logic.civilization.MapUnitAction import com.unciv.logic.civilization.MapUnitAction
import com.unciv.logic.civilization.NotificationCategory import com.unciv.logic.civilization.NotificationCategory
@ -16,25 +14,19 @@ import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.civilization.PlayerType import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.civilization.PopupAlert import com.unciv.logic.civilization.PopupAlert
import com.unciv.logic.civilization.PromoteUnitAction import com.unciv.logic.civilization.PromoteUnitAction
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile import com.unciv.logic.map.tile.Tile
import com.unciv.models.UnitActionType import com.unciv.models.UnitActionType
import com.unciv.ui.components.UnitMovementMemoryType
import com.unciv.models.ruleset.unique.StateForConditionals import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.Unique import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueTriggerActivation import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.stats.Stat import com.unciv.models.stats.Stat
import com.unciv.models.stats.Stats import com.unciv.models.stats.Stats
import com.unciv.ui.components.extensions.toPercent import com.unciv.ui.components.UnitMovementMemoryType
import com.unciv.ui.screens.worldscreen.bottombar.BattleTable
import com.unciv.utils.debug import com.unciv.utils.debug
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.ulp
import kotlin.random.Random import kotlin.random.Random
/** /**
@ -92,7 +84,7 @@ object Battle {
*/ */
fun attackOrNuke(attacker: ICombatant, attackableTile: AttackableTile): DamageDealt { fun attackOrNuke(attacker: ICombatant, attackableTile: AttackableTile): DamageDealt {
return if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) { return if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) {
NUKE(attacker, attackableTile.tileToAttack) Nuke.NUKE(attacker, attackableTile.tileToAttack)
DamageDealt.None DamageDealt.None
} else { } else {
attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!) attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!)
@ -439,7 +431,7 @@ object Battle {
} }
} }
private fun postBattleNotifications( internal fun postBattleNotifications(
attacker: ICombatant, attacker: ICombatant,
defender: ICombatant, defender: ICombatant,
attackedTile: Tile, attackedTile: Tile,
@ -626,9 +618,7 @@ object Battle {
} else if (attackerCiv.isHuman()) { } else if (attackerCiv.isHuman()) {
// we're not taking our former capital // we're not taking our former capital
attackerCiv.popupAlerts.add(PopupAlert(AlertType.CityConquered, city.id)) attackerCiv.popupAlerts.add(PopupAlert(AlertType.CityConquered, city.id))
} else { } else automateCityConquer(attackerCiv, city)
NextTurnAutomation.onConquerCity(attackerCiv, city)
}
if (attackerCiv.isCurrentPlayer()) if (attackerCiv.isCurrentPlayer())
UncivGame.Current.settings.addCompletedTutorialTask("Conquer a city") UncivGame.Current.settings.addCompletedTutorialTask("Conquer a city")
@ -638,6 +628,31 @@ object Battle {
UniqueTriggerActivation.triggerCivwideUnique(unique, attackerCiv, city) UniqueTriggerActivation.triggerCivwideUnique(unique, attackerCiv, city)
} }
/** Handle decision making after city conquest, namely whether the AI should liberate, puppet,
* or raze a city */
private fun automateCityConquer(civInfo: Civilization, city: City) {
if (!city.hasDiplomaticMarriage()) {
val foundingCiv = civInfo.gameInfo.getCivilization(city.foundingCiv)
var valueAlliance = NextTurnAutomation.valueCityStateAlliance(civInfo, foundingCiv)
if (civInfo.getHappiness() < 0)
valueAlliance -= civInfo.getHappiness() // put extra weight on liberating if unhappy
if (foundingCiv.isCityState() && city.civ != civInfo && foundingCiv != civInfo
&& !civInfo.isAtWarWith(foundingCiv)
&& valueAlliance > 0) {
city.liberateCity(civInfo)
return
}
}
city.puppetCity(civInfo)
if ((city.population.population < 4 || civInfo.isCityState())
&& city.foundingCiv != civInfo.civName && city.canBeDestroyed(justCaptured = true)) {
// raze if attacker is a city state
if (!civInfo.hasUnique(UniqueType.MayNotAnnexCities)) city.annexCity()
city.isBeingRazed = true
}
}
fun getMapCombatantOfTile(tile: Tile): ICombatant? { fun getMapCombatantOfTile(tile: Tile): ICombatant? {
if (tile.isCityCenter()) return CityCombatant(tile.getCity()!!) if (tile.isCityCenter()) return CityCombatant(tile.getCity()!!)
if (tile.militaryUnit != null) return MapUnitCombatant(tile.militaryUnit!!) if (tile.militaryUnit != null) return MapUnitCombatant(tile.militaryUnit!!)
@ -751,245 +766,6 @@ 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 {
if (nuke.getTile() == targetTile) return false
// Can only nuke visible Tiles
if (!targetTile.isVisible(nuke.getCivInfo())) return false
var canNuke = true
val attackerCiv = nuke.getCivInfo()
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 blastRadius = nuke.unit.getNukeBlastRadius()
for (tile in targetTile.getTilesInDistance(blastRadius)) {
checkDefenderCiv(tile.getOwner())
checkDefenderCiv(getMapCombatantOfTile(tile)?.getCivInfo())
}
return canNuke
}
@Suppress("FunctionName") // Yes we want this name to stand out
fun NUKE(attacker: MapUnitCombatant, targetTile: Tile) {
val attackingCiv = attacker.getCivInfo()
val notifyDeclaredWarCivs = ArrayList<Civilization>()
fun tryDeclareWar(civSuffered: Civilization) {
if (civSuffered != attackingCiv
&& civSuffered.knows(attackingCiv)
&& civSuffered.getDiplomacyManager(attackingCiv).diplomaticStatus != DiplomaticStatus.War
) {
attackingCiv.getDiplomacyManager(civSuffered).declareWar()
if (!notifyDeclaredWarCivs.contains(civSuffered)) notifyDeclaredWarCivs.add(civSuffered)
}
}
val nukeStrength = attacker.unit.getMatchingUniques(UniqueType.NuclearWeapon)
.firstOrNull()?.params?.get(0)?.toInt() ?: return
val blastRadius = attacker.unit.getMatchingUniques(UniqueType.BlastRadius)
.firstOrNull()?.params?.get(0)?.toInt() ?: 2
// Calculate the tiles that are hit
val hitTiles = targetTile.getTilesInDistance(blastRadius)
val hitCivsTerritory = ArrayList<Civilization>()
// Declare war on the owners of all hit tiles
for (hitCiv in hitTiles.mapNotNull { it.getOwner() }.distinct()) {
hitCivsTerritory.add(hitCiv)
tryDeclareWar(hitCiv)
}
// Declare war on all potentially hit units. They'll try to intercept the nuke before it drops
for (civWhoseUnitWasAttacked in hitTiles
.flatMap { it.getUnits() }
.map { it.civ }.distinct()
.filter { it != attackingCiv }) {
tryDeclareWar(civWhoseUnitWasAttacked)
if (attacker.unit.baseUnit.isAirUnit() && !attacker.isDefeated()) {
tryInterceptAirAttack(attacker, targetTile, civWhoseUnitWasAttacked, null)
}
}
val nukeNotificationAction = sequenceOf( LocationAction(targetTile.position), CivilopediaAction("Units/" + attacker.getName()))
// If the nuke has been intercepted and destroyed then it fails to detonate
if (attacker.isDefeated()) {
// Notify attacker that they are now at war for the attempt
for (defendingCiv in notifyDeclaredWarCivs)
attackingCiv.addNotification("After an attempted attack by our [${attacker.getName()}], [${defendingCiv}] has declared war on us!", nukeNotificationAction, NotificationCategory.Diplomacy, defendingCiv.civName, NotificationIcon.War, attacker.getName())
return
}
// Notify attacker that they are now at war
for (defendingCiv in notifyDeclaredWarCivs)
attackingCiv.addNotification("After being hit by our [${attacker.getName()}], [${defendingCiv}] has declared war on us!", nukeNotificationAction, NotificationCategory.Diplomacy, defendingCiv.civName, NotificationIcon.War, attacker.getName())
attacker.unit.attacksSinceTurnStart.add(Vector2(targetTile.position))
for (tile in hitTiles) {
// Handle complicated effects
doNukeExplosionForTile(attacker, tile, nukeStrength, targetTile == tile)
}
// Message all other civs
for (otherCiv in attackingCiv.gameInfo.civilizations) {
if (!otherCiv.isAlive() || otherCiv == attackingCiv) continue
if (hitCivsTerritory.contains(otherCiv))
otherCiv.addNotification("A(n) [${attacker.getName()}] from [${attackingCiv.civName}] has exploded in our territory!",
nukeNotificationAction, NotificationCategory.War, attackingCiv.civName, NotificationIcon.War, attacker.getName())
else if (otherCiv.knows(attackingCiv))
otherCiv.addNotification("A(n) [${attacker.getName()}] has been detonated by [${attackingCiv.civName}]!",
nukeNotificationAction, NotificationCategory.War, attackingCiv.civName, NotificationIcon.War, attacker.getName())
else
otherCiv.addNotification("A(n) [${attacker.getName()}] has been detonated by an unkown civilization!",
nukeNotificationAction, NotificationCategory.War, NotificationIcon.War, attacker.getName())
}
// Instead of postBattleAction() just destroy the unit, all other functions are not relevant
if (attacker.unit.hasUnique(UniqueType.SelfDestructs)) attacker.unit.destroy()
// It's unclear whether using nukes results in a penalty with all civs, or only affected civs.
// For now I'll make it give a diplomatic penalty to all known civs, but some testing for this would be appreciated
for (civ in attackingCiv.getKnownCivs()) {
civ.getDiplomacyManager(attackingCiv).setModifier(DiplomaticModifiers.UsedNuclearWeapons, -50f)
}
if (!attacker.isDefeated()) {
attacker.unit.attacksThisTurn += 1
}
}
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
// original source code: GenerateNuclearExplosionDamage(), ApplyNuclearExplosionDamage()
var damageModifierFromMissingResource = 1f
val civResources = attacker.getCivInfo().getCivResourcesByName()
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())
}
// 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 (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.removeImprovement()
} else {
tile.setPillaged()
}
}
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) {
// 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())
// 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()
}
return modifier
}
// Should draw an Interception if available on the tile from any Civ // Should draw an Interception if available on the tile from any Civ
// Land Units deal 0 damage, and no XP for either party // Land Units deal 0 damage, and no XP for either party
// Air Interceptors do Air Combat as if Melee (mutual damage) but using Ranged Strength. 5XP to both // Air Interceptors do Air Combat as if Melee (mutual damage) but using Ranged Strength. 5XP to both
@ -1103,7 +879,7 @@ object Battle {
attacker.unit.action = null attacker.unit.action = null
} }
private fun tryInterceptAirAttack( internal fun tryInterceptAirAttack(
attacker: MapUnitCombatant, attacker: MapUnitCombatant,
attackedTile: Tile, attackedTile: Tile,
interceptingCiv: Civilization, interceptingCiv: Civilization,

View File

@ -0,0 +1,265 @@
package com.unciv.logic.battle
import com.badlogic.gdx.math.Vector2
import com.unciv.logic.automation.unit.SpecificUnitAutomation
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.CivilopediaAction
import com.unciv.logic.civilization.LocationAction
import com.unciv.logic.civilization.NotificationCategory
import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.components.extensions.toPercent
import com.unciv.ui.screens.worldscreen.bottombar.BattleTable
import kotlin.math.ulp
import kotlin.random.Random
object Nuke {
/**
* 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 {
if (nuke.getTile() == targetTile) return false
// Can only nuke visible Tiles
if (!targetTile.isVisible(nuke.getCivInfo())) return false
var canNuke = true
val attackerCiv = nuke.getCivInfo()
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 blastRadius = nuke.unit.getNukeBlastRadius()
for (tile in targetTile.getTilesInDistance(blastRadius)) {
checkDefenderCiv(tile.getOwner())
checkDefenderCiv(Battle.getMapCombatantOfTile(tile)?.getCivInfo())
}
return canNuke
}
@Suppress("FunctionName") // Yes we want this name to stand out
fun NUKE(attacker: MapUnitCombatant, targetTile: Tile) {
val attackingCiv = attacker.getCivInfo()
val notifyDeclaredWarCivs = ArrayList<Civilization>()
fun tryDeclareWar(civSuffered: Civilization) {
if (civSuffered != attackingCiv
&& civSuffered.knows(attackingCiv)
&& civSuffered.getDiplomacyManager(attackingCiv).diplomaticStatus != DiplomaticStatus.War
) {
attackingCiv.getDiplomacyManager(civSuffered).declareWar()
if (!notifyDeclaredWarCivs.contains(civSuffered)) notifyDeclaredWarCivs.add(civSuffered)
}
}
val nukeStrength = attacker.unit.getMatchingUniques(UniqueType.NuclearWeapon)
.firstOrNull()?.params?.get(0)?.toInt() ?: return
val blastRadius = attacker.unit.getMatchingUniques(UniqueType.BlastRadius)
.firstOrNull()?.params?.get(0)?.toInt() ?: 2
// Calculate the tiles that are hit
val hitTiles = targetTile.getTilesInDistance(blastRadius)
val hitCivsTerritory = ArrayList<Civilization>()
// Declare war on the owners of all hit tiles
for (hitCiv in hitTiles.mapNotNull { it.getOwner() }.distinct()) {
hitCivsTerritory.add(hitCiv)
tryDeclareWar(hitCiv)
}
// Declare war on all potentially hit units. They'll try to intercept the nuke before it drops
for (civWhoseUnitWasAttacked in hitTiles
.flatMap { it.getUnits() }
.map { it.civ }.distinct()
.filter { it != attackingCiv }) {
tryDeclareWar(civWhoseUnitWasAttacked)
if (attacker.unit.baseUnit.isAirUnit() && !attacker.isDefeated()) {
Battle.tryInterceptAirAttack(attacker, targetTile, civWhoseUnitWasAttacked, null)
}
}
val nukeNotificationAction = sequenceOf( LocationAction(targetTile.position), CivilopediaAction("Units/" + attacker.getName()))
// If the nuke has been intercepted and destroyed then it fails to detonate
if (attacker.isDefeated()) {
// Notify attacker that they are now at war for the attempt
for (defendingCiv in notifyDeclaredWarCivs)
attackingCiv.addNotification("After an attempted attack by our [${attacker.getName()}], [${defendingCiv}] has declared war on us!", nukeNotificationAction, NotificationCategory.Diplomacy, defendingCiv.civName, NotificationIcon.War, attacker.getName())
return
}
// Notify attacker that they are now at war
for (defendingCiv in notifyDeclaredWarCivs)
attackingCiv.addNotification("After being hit by our [${attacker.getName()}], [${defendingCiv}] has declared war on us!", nukeNotificationAction, NotificationCategory.Diplomacy, defendingCiv.civName, NotificationIcon.War, attacker.getName())
attacker.unit.attacksSinceTurnStart.add(Vector2(targetTile.position))
for (tile in hitTiles) {
// Handle complicated effects
doNukeExplosionForTile(attacker, tile, nukeStrength, targetTile == tile)
}
// Message all other civs
for (otherCiv in attackingCiv.gameInfo.civilizations) {
if (!otherCiv.isAlive() || otherCiv == attackingCiv) continue
if (hitCivsTerritory.contains(otherCiv))
otherCiv.addNotification("A(n) [${attacker.getName()}] from [${attackingCiv.civName}] has exploded in our territory!",
nukeNotificationAction, NotificationCategory.War, attackingCiv.civName, NotificationIcon.War, attacker.getName())
else if (otherCiv.knows(attackingCiv))
otherCiv.addNotification("A(n) [${attacker.getName()}] has been detonated by [${attackingCiv.civName}]!",
nukeNotificationAction, NotificationCategory.War, attackingCiv.civName, NotificationIcon.War, attacker.getName())
else
otherCiv.addNotification("A(n) [${attacker.getName()}] has been detonated by an unkown civilization!",
nukeNotificationAction, NotificationCategory.War, NotificationIcon.War, attacker.getName())
}
// Instead of postBattleAction() just destroy the unit, all other functions are not relevant
if (attacker.unit.hasUnique(UniqueType.SelfDestructs)) attacker.unit.destroy()
// It's unclear whether using nukes results in a penalty with all civs, or only affected civs.
// For now I'll make it give a diplomatic penalty to all known civs, but some testing for this would be appreciated
for (civ in attackingCiv.getKnownCivs()) {
civ.getDiplomacyManager(attackingCiv).setModifier(DiplomaticModifiers.UsedNuclearWeapons, -50f)
}
if (!attacker.isDefeated()) {
attacker.unit.attacksThisTurn += 1
}
}
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
// original source code: GenerateNuclearExplosionDamage(), ApplyNuclearExplosionDamage()
var damageModifierFromMissingResource = 1f
val civResources = attacker.getCivInfo().getCivResourcesByName()
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)
Battle.postBattleNotifications(attacker, CityCombatant(city), city.getCenterTile())
Battle.destroyIfDefeated(city.civ, attacker.getCivInfo())
}
// 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 (unit.isCivilian()) {
if (unit.health - damage <= 40) unit.destroy() // Civ5: NUKE_NON_COMBAT_DEATH_THRESHOLD = 60
} else {
defender.takeDamage(damage)
}
Battle.postBattleNotifications(attacker, defender, defender.getTile())
Battle.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.removeImprovement()
} else {
tile.setPillaged()
}
}
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) {
// 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())
// 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()
}
return modifier
}
}

View File

@ -10,6 +10,7 @@ import com.unciv.logic.battle.BattleDamage
import com.unciv.logic.battle.CityCombatant import com.unciv.logic.battle.CityCombatant
import com.unciv.logic.battle.ICombatant import com.unciv.logic.battle.ICombatant
import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.Nuke
import com.unciv.logic.battle.TargetHelper import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.map.tile.Tile import com.unciv.logic.map.tile.Tile
import com.unciv.models.UncivSound import com.unciv.models.UncivSound
@ -21,9 +22,9 @@ import com.unciv.ui.components.UnitGroup
import com.unciv.ui.components.extensions.addBorderAllowOpacity import com.unciv.ui.components.extensions.addBorderAllowOpacity
import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.ImageGetter import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen import com.unciv.ui.screens.worldscreen.WorldScreen
@ -303,7 +304,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
attackerNameWrapper.add(attackerLabel) attackerNameWrapper.add(attackerLabel)
add(attackerNameWrapper) add(attackerNameWrapper)
val canNuke = Battle.mayUseNuke(attacker, targetTile) val canNuke = Nuke.mayUseNuke(attacker, targetTile)
val blastRadius = attacker.unit.getNukeBlastRadius() val blastRadius = attacker.unit.getNukeBlastRadius()
@ -330,7 +331,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
} }
else { else {
attackButton.onClick(attacker.getAttackSound()) { attackButton.onClick(attacker.getAttackSound()) {
Battle.NUKE(attacker, targetTile) Nuke.NUKE(attacker, targetTile)
worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking
worldScreen.shouldUpdate = true worldScreen.shouldUpdate = true
} }