diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 6240399150..afba428dc2 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -1778,6 +1778,15 @@ Your spy lost the election in [cityStateName] to [civName]! = The election in [cityStateName] were rigged by [civName]! = Your spy lost the election in [cityName]! = +# City-Ctate Coups +Stage Coup = +Your spy [spyName] successfully staged a coup in [cityName]! = +A spy from [civName] successfully staged a coup in our former ally [cityStateName]! = +A spy from [civName] successfully staged a coup in [cityStateName]! = +A spy from [civName] failed to stage a coup in our ally [cityStateName] and was killed! = +Our spy [spyName] failed to stage a coup in [cityStateName] and was killed! = +Do you want to stage a coup in [civName] with a [percent]% chance of success? = + # Spy fleeing city After the city of [cityName] was destroyed, your spy [spyName] has fled back to our hideout. = After the city of [cityName] was conquered, your spy [spyName] has fled back to our hideout. = diff --git a/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt b/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt index 240460cd90..882eac7bc8 100644 --- a/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt @@ -1,6 +1,7 @@ package com.unciv.logic.automation.unit import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.diplomacy.RelationshipLevel import com.unciv.models.Spy import com.unciv.models.SpyAction import kotlin.random.Random @@ -48,6 +49,7 @@ class EspionageAutomation(val civInfo: Civilization) { val randomCity = civInfo.gameInfo.getCities().filter { spy.canMoveTo(it) }.toList().randomOrNull() spy.moveTo(randomCity) } + spies.forEach { checkIfShouldStageCoup(it) } } /** @@ -79,4 +81,16 @@ class EspionageAutomation(val civInfo: Civilization) { spy.moveTo(civInfo.cities.filter { spy.canMoveTo(it) }.randomOrNull()) return spy.action == SpyAction.CounterIntelligence } + + private fun checkIfShouldStageCoup(spy: Spy) { + if (!spy.canDoCoup()) return + if (spy.getCoupChanceOfSuccess(false) < .7) return + val allyCiv = spy.getCity().civ.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) } + // Don't coup ally city-states + if (allyCiv != null && civInfo.getDiplomacyManager(allyCiv).isRelationshipLevelGE(RelationshipLevel.Friend)) return + val spies = civInfo.espionageManager.spyList + val randomSeed = spies.size + spies.indexOf(spy) + civInfo.gameInfo.turns + val randomAction = Random(randomSeed).nextInt(100) + if (randomAction < 20) spy.setAction(SpyAction.Coup, 1) + } } diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt index 573435ebf4..f24f30c0e0 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DiplomacyManager.kt @@ -323,6 +323,15 @@ class DiplomacyManager() : IsPartOfGameInfoSerialization { setInfluence(influence + amount) } + /** + * Reduces the influence to zero, or if they have negative influence does nothing + * @param amount A positive value to subtract from the influecne + */ + fun reduceInfluence(amount: Float) { + if (influence <= 0) return + influence = (influence - amount).coerceAtLeast(0f) + } + fun setInfluence(amount: Float) { influence = max(amount, MINIMUM_INFLUENCE) civInfo.cityStateFunctions.updateAllyCivForCityState() diff --git a/core/src/com/unciv/models/Spy.kt b/core/src/com/unciv/models/Spy.kt index 19ba814a3f..a843f7fe47 100644 --- a/core/src/com/unciv/models/Spy.kt +++ b/core/src/com/unciv/models/Spy.kt @@ -25,6 +25,7 @@ enum class SpyAction(val displayString: String, val hasTurns: Boolean, internal RiggingElections("Rigging Elections", false, true) { override fun isDoingWork(spy: Spy) = !spy.civInfo.isAtWarWith(spy.getCity().civ) }, + Coup("Coup", true, true, true), CounterIntelligence("Counter-intelligence", false, true) { override fun isDoingWork(spy: Spy) = spy.turnsRemainingForAction > 0 }, @@ -79,7 +80,7 @@ class Spy private constructor() : IsPartOfGameInfoSerialization { this.espionageManager = civInfo.espionageManager } - private fun setAction(newAction: SpyAction, turns: Int = 0) { + fun setAction(newAction: SpyAction, turns: Int = 0) { assert(!newAction.hasTurns || turns > 0) // hasTurns==false but turns > 0 is allowed (CounterIntelligence), hasTurns==true and turns==0 is not. action = newAction turnsRemainingForAction = turns @@ -135,6 +136,9 @@ class Spy private constructor() : IsPartOfGameInfoSerialization { // Handled in CityStateFunctions.nextTurnElections() turnsRemainingForAction = getCity().civ.cityStateTurnsUntilElection - 1 } + SpyAction.Coup -> { + initiateCoup() + } SpyAction.Dead -> { val oldSpyName = name name = espionageManager.getSpyName() @@ -203,7 +207,92 @@ class Spy private constructor() : IsPartOfGameInfoSerialization { otherCiv.getDiplomacyManager(civInfo).addModifier(DiplomaticModifiers.SpiedOnUs, -15f) } } + + fun canDoCoup(): Boolean = getCityOrNull() != null && getCity().civ.isCityState() && isSetUp() && getCity().civ.getAllyCiv() != civInfo.civName + + /** + * Initiates a coup if this spies civ is not the ally of the city-state. + * The coup will only happen at the end of the Civ's turn for save scum reasons, so a play may not reload in multiplayer. + * If successfull the coup will + */ + private fun initiateCoup() { + if (!canDoCoup()) { + // Maybe we are the new ally of the city-state + // However we know that we are still in the city and it hasn't been conquered + setAction(SpyAction.RiggingElections, 10) + return + } + val successChance = getCoupChanceOfSuccess(true) + val randomValue = Random(randomSeed()).nextFloat() + if (randomValue <= successChance) { + // Success + val cityState = getCity().civ + val pastAlly = cityState.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) } + val previousInfluence = if (pastAlly != null) cityState.getDiplomacyManager(pastAlly).getInfluence() else 80f + cityState.getDiplomacyManager(civInfo).setInfluence(previousInfluence) + + civInfo.addNotification("Your spy [$name] successfully staged a coup in [${cityState.civName}]!", getCity().location, + NotificationCategory.Espionage, NotificationIcon.Spy, cityState.civName) + if (pastAlly != null) { + cityState.getDiplomacyManager(pastAlly).reduceInfluence(20f) + pastAlly.addNotification("A spy from [${civInfo.civName}] successfully staged a coup in our former ally [${cityState.civName}]!", getCity().location, + NotificationCategory.Espionage, civInfo.civName, NotificationIcon.Spy, cityState.civName) + pastAlly.getDiplomacyManager(civInfo).addModifier(DiplomaticModifiers.SpiedOnUs, -15f) + } + for (civ in cityState.getKnownCivsWithSpectators()) { + if (civ == pastAlly || civ == civInfo) continue + civ.addNotification("A spy from [${civInfo.civName}] successfully staged a coup in [${cityState.civName}]!", getCity().location, + NotificationCategory.Espionage, civInfo.civName, NotificationIcon.Spy, cityState.civName) + if (civ.isSpectator()) continue + cityState.getDiplomacyManager(civ).reduceInfluence(10f) // Guess + } + setAction(SpyAction.RiggingElections, 10) + cityState.cityStateFunctions.updateAllyCivForCityState() + } else { + // Failure + val cityState = getCity().civ + val allyCiv = cityState.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) } + val spy = allyCiv?.espionageManager?.getSpyAssignedToCity(getCity()) + cityState.getDiplomacyManager(civInfo).addInfluence(-20f) + allyCiv?.addNotification("A spy from [${civInfo.civName}] failed to stag a coup in our ally [${cityState.civName}] and was killed!", getCity().location, + NotificationCategory.Espionage, civInfo.civName, NotificationIcon.Spy, cityState.civName) + allyCiv?.getDiplomacyManager(civInfo)?.addModifier(DiplomaticModifiers.SpiedOnUs, -10f) + + civInfo.addNotification("Our spy [$name] failed to stag a coup in [${cityState.civName}] and was killed!", getCity().location, + NotificationCategory.Espionage, civInfo.civName, NotificationIcon.Spy, cityState.civName) + + killSpy() + spy?.levelUpSpy() // Technically not in Civ V, but it's like the same thing as with counter-intelligence + } + } + + /** + * Calculates the success chance of a coup in this city state. + */ + fun getCoupChanceOfSuccess(includeUnkownFactors: Boolean): Float { + val cityState = getCity().civ + var successPercentage = 50f + + // Influence difference should always be a positive value + var influenceDifference: Float = if (cityState.getAllyCiv() != null) + cityState.getDiplomacyManager(cityState.getAllyCiv()!!).getInfluence() + else 60f + influenceDifference -= cityState.getDiplomacyManager(civInfo).getInfluence() + successPercentage -= influenceDifference / 2f + + // If we are viewing the success chance we don't want to reveal that there is a defending spy + val defendingSpy = if (includeUnkownFactors) + cityState.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) }?.espionageManager?.getSpyAssignedToCity(getCity()) + else null + + val spyRanks = getSkillModifier() - (defendingSpy?.getSkillModifier() ?: 0) + successPercentage += spyRanks / 2f // Each rank counts for 15% + + successPercentage = successPercentage.coerceIn(0f, 85f) + return successPercentage / 100f + } + fun moveTo(city: City?) { if (city == null) { // Moving to spy hideout location = null diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt index f2758006c2..4f372f6763 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt @@ -10,6 +10,7 @@ import com.unciv.UncivGame import com.unciv.logic.city.City import com.unciv.logic.civilization.Civilization import com.unciv.models.Spy +import com.unciv.models.SpyAction import com.unciv.models.translations.tr import com.unciv.ui.components.SmallButtonStyle import com.unciv.ui.components.extensions.addSeparatorVertical @@ -24,6 +25,7 @@ import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.input.onClick import com.unciv.ui.components.widgets.AutoScrollPane import com.unciv.ui.images.ImageGetter +import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.screens.pickerscreens.PickerScreen import com.unciv.ui.screens.worldscreen.WorldScreen @@ -41,7 +43,7 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS private var selectedSpy: Spy? = null // if the value == null, this means the Spy Hideout. - private var moveSpyHereButtons = hashMapOf() + private var spyActionButtons = hashMapOf() private var moveSpyButtons = hashMapOf() /** Readability shortcut */ @@ -101,7 +103,7 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS private fun updateCityList() { citySelectionTable.clear() - moveSpyHereButtons.clear() + spyActionButtons.clear() citySelectionTable.add() citySelectionTable.add("City".toLabel()).padTop(10f) citySelectionTable.add("Spy present".toLabel()).padTop(10f).row() @@ -145,8 +147,14 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS citySelectionTable.add(label).fill() citySelectionTable.add(getSpyIcons(manager.getSpiesInCity(city))) - val moveSpyHereButton = MoveToCityButton(city) - citySelectionTable.add(moveSpyHereButton) + val spy = civInfo.espionageManager.getSpyAssignedToCity(city) + if (city.civ.isCityState() && spy != null && spy.canDoCoup()) { + val coupButton = CoupButton(city, spy.action == SpyAction.Coup) + citySelectionTable.add(coupButton) + } else { + val moveSpyHereButton = MoveToCityButton(city) + citySelectionTable.add(moveSpyHereButton) + } citySelectionTable.row() } @@ -179,8 +187,12 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS add(getSpyIcon(spy)) } + private abstract inner class SpyCityActionButton : Button(SmallButtonStyle()) { + open fun setDirection(align: Int) { } + } + // city == null is interpreted as 'spy hideout' - private inner class MoveToCityButton(city: City?) : Button(SmallButtonStyle()) { + private inner class MoveToCityButton(city: City?) : SpyCityActionButton() { val arrow = ImageGetter.getArrowImage(Align.left) init { arrow.setSize(24f) @@ -192,11 +204,11 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS resetSelection() update() } - moveSpyHereButtons[this] = city + spyActionButtons[this] = city isVisible = false } - fun setDirection(align: Int) { + override fun setDirection(align: Int) { arrow.rotation = if (align == Align.right) 0f else 180f isDisabled = align == Align.right } @@ -211,7 +223,7 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS selectedSpyButton = moveSpyButton selectedSpy = spy selectedSpyButton!!.label.setText(Constants.cancel.tr()) - for ((button, city) in moveSpyHereButtons) { + for ((button, city) in spyActionButtons) { if (city == spy.getCityOrNull()) { button.isVisible = true button.setDirection(Align.right) @@ -227,7 +239,37 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS selectedSpy = null selectedSpyButton?.label?.setText("Move".tr()) selectedSpyButton = null - for ((button, _) in moveSpyHereButtons) + for ((button, _) in spyActionButtons) button.isVisible = false } + + private inner class CoupButton(city: City, isCurrentAction: Boolean) : SpyCityActionButton() { + val fist = ImageGetter.getStatIcon("Resistance") + init { + fist.setSize(24f) + add(fist).size(24f) + fist.setOrigin(Align.center) + if (isCurrentAction) fist.color = Color.WHITE + else fist.color = Color.DARK_GRAY + onClick { + val spy = selectedSpy!! + if (!isCurrentAction) { + ConfirmPopup(this@EspionageOverviewScreen, + "Do you want to stage a coup in [${city.civ.civName}] with a " + + "[${(selectedSpy!!.getCoupChanceOfSuccess(false) * 100f).toInt()}]% " + + "chance of success?", "Stage Coup") { + spy.setAction(SpyAction.Coup, 1) + fist.color = Color.DARK_GRAY + update() + }.open() + } else { + spy.setAction(SpyAction.CounterIntelligence, 10) + fist.color = Color.WHITE + update() + } + } + spyActionButtons[this] = city + isVisible = false + } + } }