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 <yairm210@hotmail.com>
This commit is contained in:
Xander Lenstra 2023-06-23 07:51:34 +02:00 committed by GitHub
parent 3c1f0f7814
commit 47e93a86bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 214 additions and 115 deletions

View File

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

View File

@ -13,7 +13,7 @@ enum class SpyFleeReason {
Other
}
class CityEspionageManager : IsPartOfGameInfoSerialization{
class CityEspionageManager : IsPartOfGameInfoSerialization {
@Transient
lateinit var city: City

View File

@ -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,

View File

@ -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<String>, action: NotificationAction?, category:NotificationCategory) : this() {
constructor(text: String, notificationIcons: ArrayList<String>, action: NotificationAction?, category: NotificationCategory) : this() {
this.text = text
this.icons = notificationIcons
this.action = action

View File

@ -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<Spy>()
var erasSpyEarnedFor = mutableListOf<String>()
val erasSpyEarnedFor = mutableSetOf<String>()
@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<Tile> {
return spyList.asSequence()
.filter { it.isSetUp() }
.mapNotNull { it.getLocation() }
.flatMap { it.getCenterTile().getTilesInDistance(1) }
}
fun getTechsToSteal(otherCiv: Civilization): Set<String> {
val techsToSteal = mutableSetOf<String>()
for (tech in otherCiv.tech.techsResearched) {
if (civInfo.tech.isResearched(tech)) continue
if (!civInfo.tech.canBeResearched(tech)) continue
techsToSteal.add(tech)
}
return techsToSteal
}
}

View File

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

View File

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

View File

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

View File

@ -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 <upon discovering [tech]>"
ReceiveFreeUnitWhenDiscoveringTech("Receive free [unit] when you discover [tech]", UniqueTarget.Global),
// Units entering Tiles

View File

@ -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"

View File

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

View File

@ -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 {