City state coup (#11586)

* Added coup success calculation

* Added a coup button

* Added a coup button functionality

* Improved coup chance calculation

* Added coup effects

* Fixed random value being too high

* Fixed percent chance roll

* Hid enemy spy factor from the chance text

* Added coup notifications

* Added translations

* Updated a notification

* Style changes

* Put some text onto multiple lines

* Fixed "failed staged" notification

* Added missing translation

* Finished fixing merge conflicts

* Added AI to city-state coups

* Coup notifications are now sent to the spectators as well

* Changed spy rank modifier to be additive
This commit is contained in:
Oskar Niesen
2024-05-15 23:02:37 -05:00
committed by GitHub
parent d4cfd4e563
commit c7a7bf1474
5 changed files with 173 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@ -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()
@ -204,6 +208,91 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
}
}
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

View File

@ -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<MoveToCityButton, City?>()
private var spyActionButtons = hashMapOf<SpyCityActionButton, City?>()
private var moveSpyButtons = hashMapOf<Spy, TextButton>()
/** 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
}
}
}