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

@ -276,7 +276,7 @@ object NextTurnAutomation {
if (popupAlert.type == AlertType.DeclarationOfFriendship) {
val requestingCiv = civInfo.gameInfo.getCivilization(popupAlert.value)
val diploManager = civInfo.getDiplomacyManager(requestingCiv)
if (civInfo.diplomacyFunctions.canSignDeclarationOfFriendshipWith(requestingCiv)
if (civInfo.diplomacyFunctions.canSignDeclarationOfFriendshipWith(requestingCiv)
&& wantsToSignDeclarationOfFrienship(civInfo,requestingCiv)) {
diploManager.signDeclarationOfFriendship()
requestingCiv.addNotification("We have signed a Declaration of Friendship with [${civInfo.civName}]!", NotificationCategory.Diplomacy, NotificationIcon.Diplomacy, civInfo.civName)
@ -284,7 +284,7 @@ object NextTurnAutomation {
diploManager.otherCivDiplomacy().setFlag(DiplomacyFlags.DeclinedDeclarationOfFriendship, 10)
requestingCiv.addNotification("[${civInfo.civName}] has denied our Declaration of Friendship!", NotificationCategory.Diplomacy, NotificationIcon.Diplomacy, civInfo.civName)
}
}
}
@ -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
if (civInfo.wantsToFocusOn(Victory.Focus.Culture) && cityState.cityStateFunctions.canProvideStat(Stat.Culture)) {
@ -790,13 +790,13 @@ object NextTurnAutomation {
val diploManager = civInfo.getDiplomacyManager(otherCiv)
// Shortcut, if it is below favorable then don't consider it
if (diploManager.isRelationshipLevelLT(RelationshipLevel.Favorable)) return false
val numOfFriends = civInfo.diplomacy.count { it.value.hasFlag(DiplomacyFlags.DeclarationOfFriendship) }
val knownCivs = civInfo.getKnownCivs().count { it.isMajorCiv() && it.isAlive() }
val allCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() } - 1 // Don't include us
val deadCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() && !it.isAlive() }
val allAliveCivs = allCivs - deadCivs
// Motivation should be constant as the number of civs changes
var motivation = diploManager.opinionOfOtherCiv().toInt() - 40
@ -808,7 +808,7 @@ object NextTurnAutomation {
ThreatLevel.VeryLow -> -5
else -> 0
}
// Try to ally with a fourth of the civs in play
val civsToAllyWith = 0.25f * allAliveCivs
if (numOfFriends < civsToAllyWith) {
@ -828,16 +828,16 @@ object NextTurnAutomation {
// Goes from -30 to 0 when we know 75% of allCivs
val civsToKnow = 0.75f * allAliveCivs
motivation -= ((civsToKnow - knownCivs) / civsToKnow * 30f).toInt().coerceAtLeast(0)
motivation -= hasAtLeastMotivationToAttack(civInfo, otherCiv, motivation / 2) * 2
return motivation > 0
}
private fun offerOpenBorders(civInfo: Civilization) {
if (!civInfo.hasUnique(UniqueType.EnablesOpenBorders)) return
val civsThatWeCanOpenBordersWith = civInfo.getKnownCivs()
.filter { it.isMajorCiv() && !civInfo.isAtWarWith(it)
.filter { it.isMajorCiv() && !civInfo.isAtWarWith(it)
&& it.hasUnique(UniqueType.EnablesOpenBorders)
&& !civInfo.getDiplomacyManager(it).hasOpenBorders
&& !civInfo.getDiplomacyManager(it).hasFlag(DiplomacyFlags.DeclinedOpenBorders) }
@ -853,7 +853,7 @@ object NextTurnAutomation {
}
}
}
fun wantsToOpenBorders(civInfo: Civilization, otherCiv: Civilization): Boolean {
if (civInfo.getDiplomacyManager(otherCiv).isRelationshipLevelLT(RelationshipLevel.Favorable)) return false
// Don't accept if they are at war with our friends, they might use our land to attack them
@ -863,7 +863,7 @@ object NextTurnAutomation {
return false
return true
}
private fun offerResearchAgreement(civInfo: Civilization) {
if (!civInfo.diplomacyFunctions.canSignResearchAgreement()) return // don't waste your time
@ -903,12 +903,12 @@ object NextTurnAutomation {
val tradeLogic = TradeLogic(civInfo, otherCiv)
tradeLogic.currentTrade.ourOffers.add(TradeOffer(Constants.defensivePact, TradeType.Treaty))
tradeLogic.currentTrade.theirOffers.add(TradeOffer(Constants.defensivePact, TradeType.Treaty))
otherCiv.tradeRequests.add(TradeRequest(civInfo.civName, tradeLogic.currentTrade.reverse()))
}
}
}
fun wantsToSignDefensivePact(civInfo: Civilization, otherCiv: Civilization): Boolean {
val diploManager = civInfo.getDiplomacyManager(otherCiv)
if (diploManager.isRelationshipLevelLT(RelationshipLevel.Ally)) return false
@ -926,10 +926,10 @@ object NextTurnAutomation {
val allCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() } - 1 // Don't include us
val deadCivs = civInfo.gameInfo.civilizations.count { it.isMajorCiv() && !it.isAlive() }
val allAliveCivs = allCivs - deadCivs
// We have to already be at RelationshipLevel.Ally, so we must have 80 oppinion of them
var motivation = diploManager.opinionOfOtherCiv().toInt() - 80
// If they are stronger than us, then we value it a lot more
// If they are weaker than us, then we don't value it
motivation += when (Automation.threatAssessment(civInfo,otherCiv)) {
@ -939,7 +939,7 @@ object NextTurnAutomation {
ThreatLevel.VeryLow -> -30
else -> 0
}
// If they have a defensive pact with another civ then we would get drawn into thier battles as well
motivation -= 10 * otherCivNonOverlappingDefensivePacts
@ -1284,31 +1284,6 @@ object NextTurnAutomation {
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 {
return getClosestCities(civ1, civ2)?.aerialDistance ?: Int.MAX_VALUE
}

View File

@ -2,9 +2,9 @@
import com.unciv.Constants
import com.unciv.logic.automation.Automation
import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.GreatGeneralImplementation
import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.Nuke
import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
@ -496,7 +496,7 @@ object SpecificUnitAutomation {
}
}
if (highestTileNukeValue > 0) {
Battle.NUKE(MapUnitCombatant(unit), tileToNuke!!)
Nuke.NUKE(MapUnitCombatant(unit), tileToNuke!!)
}
tryRelocateToNearbyAttackableCities(unit)
}
@ -507,7 +507,7 @@ object SpecificUnitAutomation {
*/
fun getNukeLocationValue(nuke: MapUnit, tile: Tile): Int {
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 tilesInBlastRadius = tile.getTilesInDistance(blastRadius)
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.UncivGame
import com.unciv.logic.automation.civilization.NextTurnAutomation
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
import com.unciv.logic.civilization.CivilopediaAction
import com.unciv.logic.civilization.LocationAction
import com.unciv.logic.civilization.MapUnitAction
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.PopupAlert
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.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.models.UnitActionType
import com.unciv.ui.components.UnitMovementMemoryType
import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
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.ui.components.UnitMovementMemoryType
import com.unciv.utils.debug
import kotlin.math.max
import kotlin.math.min
import kotlin.math.ulp
import kotlin.random.Random
/**
@ -92,7 +84,7 @@ object Battle {
*/
fun attackOrNuke(attacker: ICombatant, attackableTile: AttackableTile): DamageDealt {
return if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) {
NUKE(attacker, attackableTile.tileToAttack)
Nuke.NUKE(attacker, attackableTile.tileToAttack)
DamageDealt.None
} else {
attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!)
@ -439,7 +431,7 @@ object Battle {
}
}
private fun postBattleNotifications(
internal fun postBattleNotifications(
attacker: ICombatant,
defender: ICombatant,
attackedTile: Tile,
@ -626,9 +618,7 @@ object Battle {
} else if (attackerCiv.isHuman()) {
// we're not taking our former capital
attackerCiv.popupAlerts.add(PopupAlert(AlertType.CityConquered, city.id))
} else {
NextTurnAutomation.onConquerCity(attackerCiv, city)
}
} else automateCityConquer(attackerCiv, city)
if (attackerCiv.isCurrentPlayer())
UncivGame.Current.settings.addCompletedTutorialTask("Conquer a city")
@ -638,6 +628,31 @@ object Battle {
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? {
if (tile.isCityCenter()) return CityCombatant(tile.getCity()!!)
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
// 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
@ -1103,7 +879,7 @@ object Battle {
attacker.unit.action = null
}
private fun tryInterceptAirAttack(
internal fun tryInterceptAirAttack(
attacker: MapUnitCombatant,
attackedTile: Tile,
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.ICombatant
import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.Nuke
import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.map.tile.Tile
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.addSeparator
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.toTextButton
import com.unciv.ui.components.input.onClick
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.worldscreen.WorldScreen
@ -303,7 +304,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
attackerNameWrapper.add(attackerLabel)
add(attackerNameWrapper)
val canNuke = Battle.mayUseNuke(attacker, targetTile)
val canNuke = Nuke.mayUseNuke(attacker, targetTile)
val blastRadius = attacker.unit.getNukeBlastRadius()
@ -330,7 +331,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
}
else {
attackButton.onClick(attacker.getAttackSound()) {
Battle.NUKE(attacker, targetTile)
Nuke.NUKE(attacker, targetTile)
worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking
worldScreen.shouldUpdate = true
}