diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 306475d329..f2cb0083cc 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -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 diff --git a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt index b807cf5499..6b1ce39e9a 100644 --- a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt @@ -76,7 +76,7 @@ object NextTurnAutomation { } if (civInfo.gameInfo.isEspionageEnabled()) { // Do after cities are conquered - EspionageAutomation.automateSpies(civInfo) + EspionageAutomation(civInfo).automateSpies() } } diff --git a/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt b/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt index f2ba0cb634..f9878b766f 100644 --- a/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt +++ b/core/src/com/unciv/logic/automation/unit/EspionageAutomation.kt @@ -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 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 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 = + civsToStealFrom.sortedBy { otherCiv -> civInfo.espionageManager.spyList + .count { it.isDoingWork() && it.getLocation()?.civ == otherCiv } + }.toList() - val getCivsToStealFromSorted: List = - civsToStealFrom.sortedBy { otherCiv -> civInfo.espionageManager.spyList - .count { it.isDoingWork() && it.getLocation()?.civ == otherCiv } - }.toList() + private val cityStatesToRig: List 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 + } } diff --git a/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt b/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt index 847155619f..606a9aa6af 100644 --- a/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt +++ b/core/src/com/unciv/logic/city/managers/CityEspionageManager.kt @@ -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 { - 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) } } } diff --git a/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt b/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt index bf7e332190..046182a78b 100644 --- a/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/EspionageManager.kt @@ -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 { + return spyList.filter { it.getLocation() == city }.toMutableList() + } + + fun getSpyAssignedToCity(city: City): Spy? = spyList.firstOrNull {it.getLocation() == city} } diff --git a/core/src/com/unciv/models/Spy.kt b/core/src/com/unciv/models/Spy.kt index c083d3c7ea..3e65cac7e4 100644 --- a/core/src/com/unciv/models/Spy.kt +++ b/core/src/com/unciv/models/Spy.kt @@ -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 } diff --git a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt index 3cb11c6faa..72fc973ab1 100644 --- a/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt +++ b/core/src/com/unciv/ui/screens/overviewscreen/EspionageOverviewScreen.kt @@ -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) {