From 47e93a86bfcbbb38d5de7d9a083c0894eb7f4e28 Mon Sep 17 00:00:00 2001 From: Xander Lenstra <71121390+xlenstra@users.noreply.github.com> Date: Fri, 23 Jun 2023 07:51:34 +0200 Subject: [PATCH] Spies now occasionally steal technologies (#9629) * Spies now occasionally steal technologies * Updated the UI so it doesn't show a turn counter when that cannot be provided * Implemented changes discussed in reveiw comments * Renamed variable --------- Co-authored-by: Yair Morgenstern --- .../jsons/translations/template.properties | 13 +- .../city/managers/CityEspionageManager.kt | 2 +- .../unciv/logic/civilization/Civilization.kt | 6 +- .../unciv/logic/civilization/Notification.kt | 4 +- .../civilization/managers/EspionageManager.kt | 109 +++--------- .../transients/CivInfoTransientCache.kt | 6 +- core/src/com/unciv/models/Spy.kt | 164 ++++++++++++++++++ .../ruleset/unique/UniqueTriggerActivation.kt | 4 +- .../unciv/models/ruleset/unique/UniqueType.kt | 3 +- .../translations/TranslationFileWriter.kt | 4 +- .../overviewscreen/EspionageOverviewScreen.kt | 12 +- .../worldscreen/TechPolicyDiplomacyButtons.kt | 2 +- 12 files changed, 214 insertions(+), 115 deletions(-) create mode 100644 core/src/com/unciv/models/Spy.kt diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 03b3d935a8..55b0402279 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -927,8 +927,6 @@ Your [ourUnit] captured an enemy [theirUnit]! = Your [ourUnit] plundered [amount] [Stat] from [theirUnit] = We have captured a barbarian encampment and recovered [goldAmount] gold! = An enemy [unitType] has joined us! = -After an unknown civilization entered the [eraName], we have recruited [spyName] as a spy! = -We have recruited [spyName] as a spy! = [unitName] can be promoted! = # This might be needed for a rewrite of Germany's unique - see #7376 @@ -1650,14 +1648,21 @@ Enhancing religion = Enhanced religion = # Espionage -# As espionage is WIP and these strings are currently not shown in-game, -# feel free to not translate these strings for now +# As espionage is WIP, these strings are currently not shown in-game, +# so feel free to not translate these strings for now Spy = Spy Hideout = Spy present = Move = +After an unknown civilization entered the [eraName], we have recruited [spyName] as a spy! = +We have recruited [spyName] as a spy! = +A spy from [civilization] stole the Technology [techName] from [cityName]! = +An unidentified spy stole the Technology [techName] from [cityName]! = +Your spy [name] stole the Technology [techName] from [cityName]! = +Your spy [name] cannot steal any more techs from [civilization] as we've already researched all the technology they know! = + 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. = Due to the chaos ensuing in [cityName], your spy [spyname] has fled back to our hideout. = diff --git a/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt b/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt index 2e02fd6738..afb047ed4c 100644 --- a/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt +++ b/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt @@ -13,7 +13,7 @@ enum class SpyFleeReason { Other } -class CityEspionageManager : IsPartOfGameInfoSerialization{ +class CityEspionageManager : IsPartOfGameInfoSerialization { @Transient lateinit var city: City diff --git a/core/src/com/unciv/logic/civilization/Civilization.kt b/core/src/com/unciv/logic/civilization/Civilization.kt index ed043d3fdb..100289959f 100644 --- a/core/src/com/unciv/logic/civilization/Civilization.kt +++ b/core/src/com/unciv/logic/civilization/Civilization.kt @@ -759,13 +759,13 @@ class Civilization : IsPartOfGameInfoSerialization { } } - fun addNotification(text: String, location: Vector2, category:NotificationCategory, vararg notificationIcons: String) { + fun addNotification(text: String, location: Vector2, category: NotificationCategory, vararg notificationIcons: String) { addNotification(text, LocationAction(location), category, *notificationIcons) } - fun addNotification(text: String, category:NotificationCategory, vararg notificationIcons: String) = addNotification(text, null, category, *notificationIcons) + fun addNotification(text: String, category: NotificationCategory, vararg notificationIcons: String) = addNotification(text, null, category, *notificationIcons) - fun addNotification(text: String, action: NotificationAction?, category:NotificationCategory, vararg notificationIcons: String) { + fun addNotification(text: String, action: NotificationAction?, category: NotificationCategory, vararg notificationIcons: String) { if (playerType == PlayerType.AI) return // no point in lengthening the saved game info if no one will read it val arrayList = notificationIcons.toCollection(ArrayList()) notifications.add(Notification(text, arrayList, diff --git a/core/src/com/unciv/logic/civilization/Notification.kt b/core/src/com/unciv/logic/civilization/Notification.kt index 0bdf654421..6c130f5fc4 100644 --- a/core/src/com/unciv/logic/civilization/Notification.kt +++ b/core/src/com/unciv/logic/civilization/Notification.kt @@ -40,7 +40,7 @@ object NotificationIcon { const val War = "OtherIcons/Pillage" } -enum class NotificationCategory{ +enum class NotificationCategory { General, Trade, Diplomacy, @@ -69,7 +69,7 @@ open class Notification() : IsPartOfGameInfoSerialization { var action: NotificationAction? = null var category: String = NotificationCategory.General.name - constructor(text: String, notificationIcons: ArrayList, action: NotificationAction?, category:NotificationCategory) : this() { + constructor(text: String, notificationIcons: ArrayList, action: NotificationAction?, category: NotificationCategory) : this() { this.text = text this.icons = notificationIcons this.action = action diff --git a/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt b/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt index aeee2fc63e..9c5946e7b8 100644 --- a/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt @@ -1,106 +1,21 @@ package com.unciv.logic.civilization.managers -import com.unciv.Constants import com.unciv.logic.IsPartOfGameInfoSerialization -import com.unciv.logic.city.City import com.unciv.logic.civilization.Civilization +import com.unciv.logic.map.tile.Tile +import com.unciv.models.Spy -enum class SpyAction(val stringName: String) { - None("None"), - Moving("Moving"), - EstablishNetwork("Establishing Network"), - StealingTech("Stealing Tech"), - RiggingElections("Rigging Elections"), - CounterIntelligence("Conducting Counter-intelligence") -} - - -class Spy() : IsPartOfGameInfoSerialization { - // `location == null` means that the spy is in its hideout - var location: String? = null - lateinit var name: String - var timeTillActionFinish = 0 - var action = SpyAction.None - - @Transient - lateinit var civInfo: Civilization - - constructor(name: String) : this() { - this.name = name - } - - fun clone(): Spy { - val toReturn = Spy(name) - toReturn.location = location - toReturn.timeTillActionFinish = timeTillActionFinish - toReturn.action = action - return toReturn - } - - fun setTransients(civInfo: Civilization) { - this.civInfo = civInfo - } - - fun endTurn() { - --timeTillActionFinish - if (timeTillActionFinish != 0) return - - when (action) { - SpyAction.Moving -> { - action = SpyAction.EstablishNetwork - timeTillActionFinish = 3 // Dependent on cultural familiarity level if that is ever implemented - } - SpyAction.EstablishNetwork -> { - val location = getLocation()!! // This should be impossible to reach as going to the hideout sets your action to None. - action = - if (location.civ.isCityState()) { - SpyAction.RiggingElections - } else if (location.civ == civInfo) { - SpyAction.CounterIntelligence - } else { - SpyAction.StealingTech - } - } - else -> { - ++timeTillActionFinish // Not implemented yet, so don't do anything - } - } - } - - fun moveTo(city: City?) { - location = city?.id - if (city == null) { // Moving to spy hideout - action = SpyAction.None - timeTillActionFinish = 0 - return - } - action = SpyAction.Moving - timeTillActionFinish = 1 - } - - fun isSetUp() = action !in listOf(SpyAction.Moving, SpyAction.None, SpyAction.EstablishNetwork) - - fun getLocation(): City? { - return civInfo.gameInfo.getCities().firstOrNull { it.id == location } - } - - fun getLocationName(): String { - return getLocation()?.name ?: Constants.spyHideout - } -} class EspionageManager : IsPartOfGameInfoSerialization { - var spyCount = 0 var spyList = mutableListOf() - var erasSpyEarnedFor = mutableListOf() + val erasSpyEarnedFor = mutableSetOf() @Transient lateinit var civInfo: Civilization fun clone(): EspionageManager { val toReturn = EspionageManager() - toReturn.spyCount = spyCount toReturn.spyList.addAll(spyList.map { it.clone() }) toReturn.erasSpyEarnedFor.addAll(erasSpyEarnedFor) return toReturn @@ -130,7 +45,23 @@ class EspionageManager : IsPartOfGameInfoSerialization { val newSpy = Spy(spyName) newSpy.setTransients(civInfo) spyList.add(newSpy) - ++spyCount return spyName } + + fun getTilesVisibleViaSpies(): Sequence { + return spyList.asSequence() + .filter { it.isSetUp() } + .mapNotNull { it.getLocation() } + .flatMap { it.getCenterTile().getTilesInDistance(1) } + } + + fun getTechsToSteal(otherCiv: Civilization): Set { + val techsToSteal = mutableSetOf() + for (tech in otherCiv.tech.techsResearched) { + if (civInfo.tech.isResearched(tech)) continue + if (!civInfo.tech.canBeResearched(tech)) continue + techsToSteal.add(tech) + } + return techsToSteal + } } diff --git a/core/src/com/unciv/logic/civilization/transients/CivInfoTransientCache.kt b/core/src/com/unciv/logic/civilization/transients/CivInfoTransientCache.kt index 6acb63e90b..5645ea6d96 100644 --- a/core/src/com/unciv/logic/civilization/transients/CivInfoTransientCache.kt +++ b/core/src/com/unciv/logic/civilization/transients/CivInfoTransientCache.kt @@ -182,11 +182,7 @@ class CivInfoTransientCache(val civInfo: Civilization) { } } - for (spy in civInfo.espionageManager.spyList) { - val spyCity = spy.getLocation() ?: continue - if (!spy.isSetUp()) continue // Can't see cities when you haven't set up yet - newViewableTiles.addAll(spyCity.getCenterTile().getTilesInDistance(1)) - } + newViewableTiles.addAll(civInfo.espionageManager.getTilesVisibleViaSpies()) civInfo.viewableTiles = newViewableTiles // to avoid concurrent modification problems } diff --git a/core/src/com/unciv/models/Spy.kt b/core/src/com/unciv/models/Spy.kt new file mode 100644 index 0000000000..5dcf4f43c6 --- /dev/null +++ b/core/src/com/unciv/models/Spy.kt @@ -0,0 +1,164 @@ +package com.unciv.models + +import com.unciv.Constants +import com.unciv.logic.IsPartOfGameInfoSerialization +import com.unciv.logic.city.City +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.civilization.NotificationCategory +import com.unciv.logic.civilization.NotificationIcon +import com.unciv.logic.civilization.managers.EspionageManager +import kotlin.random.Random + + +enum class SpyAction(val displayString: String) { + None("None"), + Moving("Moving"), + EstablishNetwork("Establishing Network"), + Surveillance("Observing City"), + StealingTech("Stealing Tech"), + RiggingElections("Rigging Elections"), + CounterIntelligence("Conducting Counter-intelligence") +} + + +class Spy() : IsPartOfGameInfoSerialization { + // `location == null` means that the spy is in its hideout + var location: String? = null + lateinit var name: String + var action = SpyAction.None + private set + var turnsRemainingForAction = 0 + private set + private var progressTowardsStealingTech = 0 + + @Transient + lateinit var civInfo: Civilization + + @Transient + private lateinit var espionageManager: EspionageManager + + constructor(name: String) : this() { + this.name = name + } + + fun clone(): Spy { + val toReturn = Spy(name) + toReturn.location = location + toReturn.action = action + toReturn.turnsRemainingForAction = turnsRemainingForAction + toReturn.progressTowardsStealingTech = progressTowardsStealingTech + return toReturn + } + + fun setTransients(civInfo: Civilization) { + this.civInfo = civInfo + this.espionageManager = civInfo.espionageManager + } + + fun endTurn() { + when (action) { + SpyAction.None -> return + SpyAction.Moving -> { + --turnsRemainingForAction + if (turnsRemainingForAction > 0) return + + action = SpyAction.EstablishNetwork + turnsRemainingForAction = 3 // Depending on cultural familiarity level if that is ever implemented + } + SpyAction.EstablishNetwork -> { + --turnsRemainingForAction + if (turnsRemainingForAction > 0) return + + val location = getLocation()!! // This should never throw an exception, as going to the hideout sets your action to None. + if (location.civ.isCityState()) { + action = SpyAction.RiggingElections + } else if (location.civ == civInfo) { + action = SpyAction.CounterIntelligence + } else { + startStealingTech() + } + } + SpyAction.Surveillance -> { + if (!getLocation()!!.civ.isMajorCiv()) return + + val stealableTechs = espionageManager.getTechsToSteal(getLocation()!!.civ) + if (stealableTechs.isEmpty()) return + action = SpyAction.StealingTech // There are new techs to steal! + } + SpyAction.StealingTech -> { + val stealableTechs = espionageManager.getTechsToSteal(getLocation()!!.civ) + if (stealableTechs.isEmpty()) { + action = SpyAction.Surveillance + turnsRemainingForAction = 0 + val notificationString = "Your spy [$name] cannot steal any more techs from [${getLocation()!!.civ}] as we've already researched all the technology they know!" + civInfo.addNotification(notificationString, getLocation()!!.location, NotificationCategory.Espionage, NotificationIcon.Spy) + return + } + val techStealCost = stealableTechs.maxOfOrNull { civInfo.gameInfo.ruleset.technologies[it]!!.cost }!! + val progressThisTurn = getLocation()!!.cityStats.currentCityStats.science + progressTowardsStealingTech += progressThisTurn.toInt() + if (progressTowardsStealingTech > techStealCost) { + stealTech() + } + } + else -> return // Not implemented yet, so don't do anything + } + } + + fun startStealingTech() { + action = SpyAction.StealingTech + turnsRemainingForAction = 0 + progressTowardsStealingTech = 0 + } + + private fun stealTech() { + val city = getLocation()!! + val otherCiv = city.civ + val randomSeed = city.location.x * city.location.y + 123f * civInfo.gameInfo.turns + + val stolenTech = espionageManager.getTechsToSteal(getLocation()!!.civ) + .randomOrNull(Random(randomSeed.toInt())) // Could be improved to for example steal the most expensive tech or the tech that has the least progress as of yet + if (stolenTech != null) { + civInfo.tech.addTechnology(stolenTech) + } + + val spyDetected = Random(randomSeed.toInt()).nextInt(3) + val detectionString = when (spyDetected) { + 0 -> "A spy from [${civInfo.civName}] stole the Technology [$stolenTech] from [$city]!" + 1 -> "An unidentified spy stole the Technology [$stolenTech] from [$city]!" + else -> null // Not detected + } + if (detectionString != null) + otherCiv.addNotification(detectionString, city.location, NotificationCategory.Espionage, NotificationIcon.Spy) + + civInfo.addNotification("Your spy [$name] stole the Technology [$stolenTech] from [$city]!", city.location, + NotificationCategory.Espionage, + NotificationIcon.Spy + ) + + + startStealingTech() + } + + + fun moveTo(city: City?) { + location = city?.id + if (city == null) { // Moving to spy hideout + action = SpyAction.None + turnsRemainingForAction = 0 + return + } + action = SpyAction.Moving + turnsRemainingForAction = 1 + } + + fun isSetUp() = action !in listOf(SpyAction.Moving, SpyAction.None, SpyAction.EstablishNetwork) + + fun getLocation(): City? { + return civInfo.gameInfo.getCities().firstOrNull { it.id == location } + } + + fun getLocationName(): String { + return getLocation()?.name ?: Constants.spyHideout + } +} diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt index 08306e1fab..550da91b5a 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt @@ -622,9 +622,9 @@ object UniqueTriggerActivation { otherCiv.espionageManager.erasSpyEarnedFor.add(currentEra) if (otherCiv == civInfo || otherCiv.knows(civInfo)) // We don't tell which civilization entered the new era, as that is done in the notification directly above this one - otherCiv.addNotification("We have recruited [${spyName}] as a spy!", NotificationCategory.General, NotificationIcon.Spy) + otherCiv.addNotification("We have recruited [${spyName}] as a spy!", NotificationCategory.Espionage, NotificationIcon.Spy) else - otherCiv.addNotification("After an unknown civilization entered the [${currentEra}], we have recruited [${spyName}] as a spy!", NotificationCategory.General, NotificationIcon.Spy) + otherCiv.addNotification("After an unknown civilization entered the [${currentEra}], we have recruited [${spyName}] as a spy!", NotificationCategory.Espionage, NotificationIcon.Spy) } } return true diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index 7308559a56..b28fa9f9fe 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -225,8 +225,7 @@ enum class UniqueType(val text: String, vararg targets: UniqueTarget, val flags: UnitSupplyPerCity("[amount] Unit Supply per city", UniqueTarget.Global), FreeUnits("[amount] units cost no maintenance", UniqueTarget.Global), UnitsInCitiesNoMaintenance("Units in cities cost no Maintenance", UniqueTarget.Global), - // Acts as a trigger - this should be generalized somehow but the current setup does not allow this - // It would currently mean cycling through EVERY unique type to find ones with a specific conditional... + // ToDo: Replace with "Free [unit] appears " ReceiveFreeUnitWhenDiscoveringTech("Receive free [unit] when you discover [tech]", UniqueTarget.Global), // Units entering Tiles diff --git a/core/src/com/unciv/models/translations/TranslationFileWriter.kt b/core/src/com/unciv/models/translations/TranslationFileWriter.kt index 9c4365401b..551378679c 100644 --- a/core/src/com/unciv/models/translations/TranslationFileWriter.kt +++ b/core/src/com/unciv/models/translations/TranslationFileWriter.kt @@ -5,7 +5,7 @@ import com.badlogic.gdx.files.FileHandle import com.unciv.json.fromJsonFile import com.unciv.json.json import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers -import com.unciv.logic.civilization.managers.SpyAction +import com.unciv.models.SpyAction import com.unciv.models.metadata.BaseRuleset import com.unciv.models.metadata.LocaleCode import com.unciv.models.ruleset.Belief @@ -127,7 +127,7 @@ object TranslationFileWriter { linesToTranslate += "\n\n#################### Lines from spy actions #######################\n" for (spyAction in SpyAction.values()) { - linesToTranslate += "$spyAction = " + linesToTranslate += "${spyAction.displayString} = " } linesToTranslate += "\n\n#################### Lines from diplomatic modifiers #######################\n" diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt index f00c31e0aa..92183f69a4 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt @@ -8,8 +8,8 @@ import com.badlogic.gdx.utils.Align import com.unciv.UncivGame import com.unciv.logic.city.City import com.unciv.logic.civilization.Civilization -import com.unciv.logic.civilization.managers.Spy -import com.unciv.logic.civilization.managers.SpyAction +import com.unciv.models.Spy +import com.unciv.models.SpyAction import com.unciv.models.translations.tr import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.pickerscreens.PickerScreen @@ -71,8 +71,12 @@ class EspionageOverviewScreen(val civInfo: Civilization) : PickerScreen(true) { spySelectionTable.add(spy.name.toLabel()).pad(10f) spySelectionTable.add(spy.getLocationName().toLabel()).pad(10f) val actionString = - if (spy.action == SpyAction.None) SpyAction.None.stringName - else "[${spy.action.stringName}] ${spy.timeTillActionFinish}${Fonts.turn}" + when (spy.action) { + SpyAction.None, SpyAction.StealingTech, SpyAction.Surveillance -> spy.action.displayString + SpyAction.Moving, SpyAction.EstablishNetwork -> "[${spy.action.displayString}] ${spy.turnsRemainingForAction}${Fonts.turn}" + SpyAction.RiggingElections -> TODO() + SpyAction.CounterIntelligence -> TODO() + } spySelectionTable.add(actionString.toLabel()).pad(10f) val moveSpyButton = "Move".toTextButton() diff --git a/core/src/com/unciv/ui/screens/worldscreen/TechPolicyDiplomacyButtons.kt b/core/src/com/unciv/ui/screens/worldscreen/TechPolicyDiplomacyButtons.kt index 5f68501895..c614d9f509 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/TechPolicyDiplomacyButtons.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/TechPolicyDiplomacyButtons.kt @@ -153,7 +153,7 @@ class TechPolicyDiplomacyButtons(val worldScreen: WorldScreen) : Table(BaseScree } private fun updateEspionageButton() { - if (viewingCiv.espionageManager.spyCount == 0) { + if (viewingCiv.espionageManager.spyList.isEmpty()) { espionageButtonHolder.touchable = Touchable.disabled espionageButtonHolder.actor = null } else {