diff --git a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt index cbf280f5c4..ddf7b86b01 100644 --- a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt @@ -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 } diff --git a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt index c2b0a61db8..cdd3252d3d 100644 --- a/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/SpecificUnitAutomation.kt @@ -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() } + diff --git a/core/src/com/unciv/logic/battle/Battle.kt b/core/src/com/unciv/logic/battle/Battle.kt index cd7b84488a..acc24b1741 100644 --- a/core/src/com/unciv/logic/battle/Battle.kt +++ b/core/src/com/unciv/logic/battle/Battle.kt @@ -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() - 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() - // 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, diff --git a/core/src/com/unciv/logic/battle/Nuke.kt b/core/src/com/unciv/logic/battle/Nuke.kt new file mode 100644 index 0000000000..8eb34a70a0 --- /dev/null +++ b/core/src/com/unciv/logic/battle/Nuke.kt @@ -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() + 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() + // 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 + } +} 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 95248507d6..e55e75230a 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt @@ -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 }