Further Espionage Implementation (#11077)

* Added getSpiesInCity method in EspionageManager

* Expanded stealing technology code

* Spies can now die and revive

* Added basic rigging elections

* Spies rigging elections can now be caught

* Added spy rank

* Players can now move spies to city-states

* Spies at a higher rank steal tech quicker

* EspionageAutomation now sends spies to city-states and to do counter intelligence

* Fixed some EspionageAutomation logic

* Fixed EspionageAutomation error

* Spy.location is now stored as a vector2

* Accounted for the only able to be one spy of a civ in each city

* Spies level up when successfully stealing tech.

* Increased tech steal rate by spy rank

* Increased influence gained by rigging elections

* Added a spy level cap

* Spies no longer get stuck on counter-intelligence action

* Spy automation no longer tries to rig elections in city states that it is at war with

* canMoveTo now checks if the city tile is visible

* Added espionage translations

* Changed automateSpySteal/Rig/Counter intelligence return types

* Simplifies automateSpies in EspionageAutomation

* Added blank lines before titles

* Improved spy being found and killed message phrasing
This commit is contained in:
Oskar Niesen 2024-02-13 15:25:07 -06:00 committed by GitHub
parent ed7fd447c2
commit ccea2c88d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 238 additions and 58 deletions

View File

@ -1729,14 +1729,28 @@ 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! =
Your spy [spyName] has leveled up! =
Your spy [spyName] cannot steal any more techs from [civName] as we've already researched all the technology they know! =
# Stealing Technology defending civ
An unidentified spy stole the Technology [techName] from [cityName]! =
A spy from [civName] stole the Technology [techName] from [cityName]! =
A spy from [civName] was found and killed trying to steal Technology in [cityName]! =
A spy from [civName] was found and killed by [spyName] trying to steal Technology in [cityName]! =
# Stealing Technology offending civ
Your spy [spyName] stole the Technology [techName] from [cityName]! =
Your spy [spyName] was killed trying to steal Technology in [cityName]! =
# Rigging elections
A spy from [civName] tried to rig elections and was found and killed in [cityName] by [spyName]! =
Your spy [spyName] was killed trying to rig the election in [cityName]! =
Your spy successfully rigged the election in [cityName]! =
# 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. =
Due to the chaos ensuing in [cityName], 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. =
# Promotions

View File

@ -76,7 +76,7 @@ object NextTurnAutomation {
}
if (civInfo.gameInfo.isEspionageEnabled()) {
// Do after cities are conquered
EspionageAutomation.automateSpies(civInfo)
EspionageAutomation(civInfo).automateSpies()
}
}

View File

@ -1,33 +1,80 @@
package com.unciv.logic.automation.unit
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.models.Spy
import com.unciv.models.SpyAction
import kotlin.random.Random
object EspionageAutomation {
class EspionageAutomation(val civInfo: Civilization) {
private val civsToStealFrom: List<Civilization> by lazy {
civInfo.getKnownCivs().filter {otherCiv -> otherCiv.isMajorCiv() && otherCiv.cities.any { it.getCenterTile().isVisible(civInfo) }
&& civInfo.espionageManager.getTechsToSteal(otherCiv).isNotEmpty() }.toList()
}
fun automateSpies(civInfo: Civilization) {
val civsToStealFrom: List<Civilization> by lazy {
civInfo.getKnownCivs().filter {otherCiv -> otherCiv.isMajorCiv() && otherCiv.cities.any { it.getCenterTile().isVisible(civInfo) }
&& civInfo.espionageManager.getTechsToSteal(otherCiv).isNotEmpty() }.toList()
}
private val getCivsToStealFromSorted: List<Civilization> =
civsToStealFrom.sortedBy { otherCiv -> civInfo.espionageManager.spyList
.count { it.isDoingWork() && it.getLocation()?.civ == otherCiv }
}.toList()
val getCivsToStealFromSorted: List<Civilization> =
civsToStealFrom.sortedBy { otherCiv -> civInfo.espionageManager.spyList
.count { it.isDoingWork() && it.getLocation()?.civ == otherCiv }
}.toList()
private val cityStatesToRig: List<Civilization> by lazy {
civInfo.getKnownCivs().filter { otherCiv -> otherCiv.isMinorCiv() && otherCiv.knows(civInfo) && !civInfo.isAtWarWith(otherCiv) }.toList()
}
for (spy in civInfo.espionageManager.spyList) {
if (spy.isDoingWork()) continue
if (civsToStealFrom.isNotEmpty()) {
// We want to move the spy to the city with the highest science generation
// Players can't usually figure this out so lets do highest population instead
spy.moveTo(getCivsToStealFromSorted.first().cities.filter { it.getCenterTile().isVisible(civInfo) }.maxByOrNull { it.population.population })
fun automateSpies() {
val spies = civInfo.espionageManager.spyList
val spiesToMove = spies.filter { it.isAlive() && !it.isDoingWork() }
for (spy in spiesToMove) {
val randomSeed = spies.size + spies.indexOf(spy) + civInfo.gameInfo.turns
val randomAction = Random(randomSeed).nextInt(10)
// Try each operation based on the random value and the success rate
// If an operation was not successfull try the next one
if (randomAction <= 7 && automateSpyStealTech(spy)) {
continue
} else if (randomAction <= 9 && automateSpyRigElection(spy)) {
continue
} else if (automateSpyCounterInteligence(spy)) {
continue
} else if (spy.isDoingWork()) {
continue // We might have been doing counter intelligence and wanted to look for something better
} else {
// Retry all of the operations one more time
if (automateSpyStealTech(spy)) continue
if (automateSpyRigElection(spy)) continue
if(automateSpyCounterInteligence(spy)) continue
}
if (spy.action == SpyAction.None) {
spy.moveTo(civInfo.getKnownCivs().filter { otherCiv -> otherCiv.isMajorCiv() && otherCiv.cities.any { it.getCenterTile().isVisible(civInfo) }}
.toList().randomOrNull()?.cities?.filter { it.getCenterTile().isVisible(civInfo) }?.randomOrNull())
}
// There is nothing for our spy to do, put it in a random city
val randomCity = civInfo.gameInfo.getCities().filter { spy.canMoveTo(it) }.toList().randomOrNull()
spy.moveTo(randomCity)
}
}
/**
* Moves the spy to a city that we can steal a tech from
*/
fun automateSpyStealTech(spy: Spy): Boolean {
if (civsToStealFrom.isEmpty()) return false
// We want to move the spy to the city with the highest science generation
// Players can't usually figure this out so lets do highest population instead
spy.moveTo(getCivsToStealFromSorted.first().cities.filter { spy.canMoveTo(it) }.maxByOrNull { it.population.population })
return spy.action == SpyAction.StealingTech
}
/**
* Moves the spy to a random city-state
*/
private fun automateSpyRigElection(spy: Spy): Boolean {
val potentialCities = cityStatesToRig.flatMap { it.cities }.filter { !it.isBeingRazed && spy.canMoveTo(it) }
spy.moveTo(potentialCities.randomOrNull())
return spy.action == SpyAction.RiggingElections
}
/**
* Moves the spy to a random city of ours
*/
private fun automateSpyCounterInteligence(spy: Spy): Boolean {
spy.moveTo(civInfo.cities.filter { spy.canMoveTo(it) }.randomOrNull())
return spy.action == SpyAction.CounterIntelligence
}
}

View File

@ -26,13 +26,11 @@ class CityEspionageManager : IsPartOfGameInfoSerialization {
}
fun hasSpyOf(civInfo: Civilization): Boolean {
return civInfo.espionageManager.spyList.any { it.location == city.id }
return civInfo.espionageManager.spyList.any { it.getLocation() == city }
}
private fun getAllStationedSpies(): List<Spy> {
return city.civ.gameInfo.civilizations.flatMap { civ ->
civ.espionageManager.spyList.filter { it.location == city.id }
}
return city.civ.gameInfo.civilizations.flatMap { it.espionageManager.getSpiesInCity(city) }
}
fun removeAllPresentSpies(reason: SpyFleeReason) {
@ -44,7 +42,7 @@ class CityEspionageManager : IsPartOfGameInfoSerialization {
else -> "Due to the chaos ensuing in [${city.name}], your spy [${spy.name}] has fled back to our hideout."
}
owningCiv.addNotification(notificationString, city.location, NotificationCategory.Espionage, NotificationIcon.Spy)
spy.location = null
spy.moveTo(null)
}
}
}

View File

@ -1,6 +1,7 @@
package com.unciv.logic.civilization.managers
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
@ -33,7 +34,7 @@ class EspionageManager : IsPartOfGameInfoSerialization {
spy.endTurn()
}
private fun getSpyName(): String {
fun getSpyName(): String {
val usedSpyNames = spyList.map { it.name }.toHashSet()
val validSpyNames = civInfo.nation.spyNames.filter { it !in usedSpyNames }
if (validSpyNames.isEmpty()) { return "Spy ${spyList.size+1}" } // +1 as non-programmers count from 1
@ -64,4 +65,10 @@ class EspionageManager : IsPartOfGameInfoSerialization {
}
return techsToSteal
}
fun getSpiesInCity(city: City): MutableList<Spy> {
return spyList.filter { it.getLocation() == city }.toMutableList()
}
fun getSpyAssignedToCity(city: City): Spy? = spyList.firstOrNull {it.getLocation() == city}
}

View File

@ -1,5 +1,6 @@
package com.unciv.models
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.city.City
@ -17,16 +18,18 @@ enum class SpyAction(val displayString: String) {
Surveillance("Observing City"),
StealingTech("Stealing Tech"),
RiggingElections("Rigging Elections"),
CounterIntelligence("Conducting Counter-intelligence")
CounterIntelligence("Conducting Counter-intelligence"),
Dead("Dead")
}
class Spy() : IsPartOfGameInfoSerialization {
// `location == null` means that the spy is in its hideout
var location: String? = null
private var location: Vector2? = null
lateinit var name: String
var action = SpyAction.None
private set
var rank: Int = 1
var turnsRemainingForAction = 0
private set
private var progressTowardsStealingTech = 0
@ -72,8 +75,10 @@ class Spy() : IsPartOfGameInfoSerialization {
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
turnsRemainingForAction = 10
} else if (location.civ == civInfo) {
action = SpyAction.CounterIntelligence
turnsRemainingForAction = 10
} else {
startStealingTech()
}
@ -95,12 +100,36 @@ class Spy() : IsPartOfGameInfoSerialization {
return
}
val techStealCost = stealableTechs.maxOfOrNull { civInfo.gameInfo.ruleset.technologies[it]!!.cost }!!
val progressThisTurn = getLocation()!!.cityStats.currentCityStats.science
// 33% spy bonus for each level
val progressThisTurn = getLocation()!!.cityStats.currentCityStats.science * (rank + 2f) / 3f
progressTowardsStealingTech += progressThisTurn.toInt()
if (progressTowardsStealingTech > techStealCost) {
stealTech()
}
}
SpyAction.RiggingElections -> {
--turnsRemainingForAction
if (turnsRemainingForAction > 0) return
rigElection()
}
SpyAction.Dead -> {
--turnsRemainingForAction
if (turnsRemainingForAction > 0) return
val oldSpyName = name
name = espionageManager.getSpyName()
action = SpyAction.None
civInfo.addNotification("We have recruited a new spy name [$name] after [$oldSpyName] was killed.",
NotificationCategory.Espionage, NotificationIcon.Spy)
}
SpyAction.CounterIntelligence -> {
// Counter inteligence spies don't do anything here
// However the AI will want to keep track of how long a spy has been doing counter intelligence for
// Once turnRemainingForAction is <= 0 the spy won't be considered to be doing work any more
--turnsRemainingForAction
return
}
else -> return // Not implemented yet, so don't do anything
}
}
@ -121,47 +150,138 @@ class Spy() : IsPartOfGameInfoSerialization {
if (stolenTech != null) {
civInfo.tech.addTechnology(stolenTech)
}
// Lower is better
var spyResult = Random(randomSeed.toInt()).nextInt(300)
// Add our spies experience
spyResult -= getSkillModifier()
// Subtract the experience of the counter inteligence spies
val defendingSpy = city.civ.espionageManager.getSpyAssignedToCity(city)
spyResult += defendingSpy?.getSkillModifier() ?: 0
//TODO: Add policies modifier here
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
val detectionString = when {
spyResult < 0 -> null // Not detected
spyResult < 100 -> "An unidentified spy stole the Technology [$stolenTech] from [$city]!"
spyResult < 200 -> "A spy from [${civInfo.civName}] stole the Technology [$stolenTech] from [$city]!"
else -> { // The spy was killed in the attempt
if (defendingSpy == null) "A spy from [${civInfo.civName}] was found and killed trying to steal Technology in [$city]!"
else "A spy from [${civInfo.civName}] was found and killed by [${defendingSpy.name}] trying to steal Technology in [$city]!"
}
}
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
)
if (spyResult < 200) {
civInfo.addNotification("Your spy [$name] stole the Technology [$stolenTech] from [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
startStealingTech()
levelUpSpy()
} else {
civInfo.addNotification("Your spy [$name] was killed trying to steal Technology in [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
defendingSpy?.levelUpSpy()
killSpy()
}
startStealingTech()
}
private fun rigElection() {
val city = getLocation()!!
val cityStateCiv = city.civ
// TODO: Simple implementation, please implement this in the future. This is a guess.
turnsRemainingForAction = 10
if (cityStateCiv.getAllyCiv() != null && cityStateCiv.getAllyCiv() != civInfo.civName) {
val allyCiv = civInfo.gameInfo.getCivilization(cityStateCiv.getAllyCiv()!!)
val defendingSpy = allyCiv.espionageManager.getSpyAssignedToCity(getLocation()!!)
if (defendingSpy != null) {
val randomSeed = city.location.x * city.location.y + 123f * civInfo.gameInfo.turns
var spyResult = Random(randomSeed.toInt()).nextInt(120)
spyResult -= getSkillModifier()
spyResult += defendingSpy.getSkillModifier()
if (spyResult > 100) {
// The Spy was killed
allyCiv.addNotification("A spy from [${civInfo.civName}] tried to rig elections and was found and killed in [${city}] by [${defendingSpy.name}]!",
getLocation()!!.location, NotificationCategory.Espionage, NotificationIcon.Spy)
civInfo.addNotification("Your spy [$name] was killed trying to rig the election in [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
killSpy()
defendingSpy.levelUpSpy()
return
}
}
}
// Starts at 10 influence and increases by 3 for each extra rank.
cityStateCiv.getDiplomacyManager(civInfo).addInfluence(7f + getSpyRank() * 3)
civInfo.addNotification("Your spy successfully rigged the election in [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
}
fun moveTo(city: City?) {
location = city?.id
if (city == null) { // Moving to spy hideout
location = null
action = SpyAction.None
turnsRemainingForAction = 0
return
}
location = city.location
action = SpyAction.Moving
turnsRemainingForAction = 1
}
fun canMoveTo(city: City): Boolean {
if (getLocation() == city) return true
if (!city.getCenterTile().isVisible(civInfo)) return false
return espionageManager.getSpyAssignedToCity(city) == null
}
fun isSetUp() = action !in listOf(SpyAction.Moving, SpyAction.None, SpyAction.EstablishNetwork)
// Only returns true if the spy is doing a helpful and implemented action
fun isDoingWork() = action == SpyAction.StealingTech || action == SpyAction.EstablishNetwork
fun isDoingWork(): Boolean {
if (action == SpyAction.StealingTech || action == SpyAction.EstablishNetwork || action == SpyAction.Moving) return true
if (action == SpyAction.RiggingElections && !civInfo.isAtWarWith(getLocation()!!.civ)) return true
if (action == SpyAction.CounterIntelligence && turnsRemainingForAction > 0) return true
else return false
}
fun getLocation(): City? {
return civInfo.gameInfo.getCities().firstOrNull { it.id == location }
if (location == null) return null
return civInfo.gameInfo.tileMap[location!!].getCity()
}
fun getLocationName(): String {
return getLocation()?.name ?: Constants.spyHideout
}
fun getSpyRank(): Int {
return rank
}
fun levelUpSpy() {
//TODO: Make the spy level cap dependent on some unique
if (rank >= 3) return
if (getLocation() != null) {
civInfo.addNotification("Your spy [$name] has leveled up!", getLocation()!!.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
} else {
civInfo.addNotification("Your spy [$name] has leveled up!",
NotificationCategory.Espionage, NotificationIcon.Spy)
}
rank++
}
fun getSkillModifier(): Int {
return getSpyRank() * 30
}
fun killSpy() {
// We don't actually remove this spy object, we set them as dead and let them revive
moveTo(null)
action = SpyAction.Dead
turnsRemainingForAction = 5
rank = 1
}
fun isAlive(): Boolean = action != SpyAction.Dead
}

View File

@ -77,10 +77,8 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
spySelectionTable.add(spy.getLocationName().toLabel())
val actionString =
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()
SpyAction.None, SpyAction.StealingTech, SpyAction.Surveillance, SpyAction.CounterIntelligence -> spy.action.displayString
SpyAction.Moving, SpyAction.EstablishNetwork, SpyAction.Dead, SpyAction.RiggingElections -> "[${spy.action.displayString}] ${spy.turnsRemainingForAction}${Fonts.turn}"
}
spySelectionTable.add(actionString.toLabel())
@ -95,14 +93,10 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
selectedSpy = spy
selectedSpyButton!!.label.setText(Constants.cancel.tr())
for ((button, city) in moveSpyHereButtons) {
// For now, only allow spies to be sent to cities of other major civs and their hideout
// Not own cities as counterintelligence isn't implemented
// Not city-state civs as rigging elections isn't implemented
button.isVisible = city == null // hideout
|| (city.civ.isMajorCiv()
&& city.civ != civInfo
&& !city.espionage.hasSpyOf(civInfo)
)
|| (city.civ != civInfo && !city.espionage.hasSpyOf(civInfo))
}
}
if (!worldScreen.canChangeState) {