Spy UI improvements (#11570)

* Minor linting

* More linting

* Consistent naming city/location, cache city on Spy (tile visibility perf)

* Empower SpyAction enum (minor perf)

* NotificationAction for Espionage, shorthand on the Spy instance

* Fix National Intelligence Agency giving one extra Spy level

* Fix "Move Spies" NextTurnButton prompt appearing every turn when spies are in Surveillance

* Fix failed tech theft rewarding tech anyway (and open spy kill chance when theft ordered but nothing to steal)

* Finally refactor SmallButtonStyle as standalone component

* Fix unable to assign spies for counter-intelligence

* Shorten establish-network phase for domestic spy placement

* Refactor and prettify MoveToCityButton, reuse as pointer who is to move (and some tiny changes)

* Decorate Spy icons by rank and show those in the hideout too

* Make city name labels in Espionage screen clickable

* Umm... duplicate targets behave same as single targets

* Spy mechanics - no establish network before counter-intelligence, commenting

* Oops, those lines do not belong in this branch anyway
This commit is contained in:
SomeTroglodyte
2024-05-11 20:36:07 +02:00
committed by GitHub
parent 7bbe218bf0
commit 628bd71830
15 changed files with 312 additions and 242 deletions

View File

@ -986,8 +986,11 @@
"cost": 120,
"culture": 1,
"isNationalWonder": true,
"uniques": ["Hidden when espionage is disabled", "Gain an extra spy", "Promotes all spies",
"[-15]% enemy spy effectiveness [in this city]", "New spies start with [1] level(s)",
"uniques": ["Hidden when espionage is disabled",
"New spies start with [1] level(s)",
"Promotes all spies",
"Gain an extra spy", // Order is significant here
"[-15]% enemy spy effectiveness [in this city]",
"Only available <if [Police Station] is constructed in all [non-[Puppeted]] cities>",
"Cost increases by [30] per owned city"],
"requiredTech": "Radio"

View File

@ -1,6 +1,5 @@
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
@ -15,7 +14,7 @@ class EspionageAutomation(val civInfo: Civilization) {
private val getCivsToStealFromSorted: List<Civilization> =
civsToStealFrom.sortedBy { otherCiv -> civInfo.espionageManager.spyList
.count { it.isDoingWork() && it.getLocation()?.civ == otherCiv }
.count { it.isDoingWork() && it.getCityOrNull()?.civ == otherCiv }
}.toList()
private val cityStatesToRig: List<Civilization> by lazy {

View File

@ -26,7 +26,7 @@ class CityEspionageManager : IsPartOfGameInfoSerialization {
}
fun hasSpyOf(civInfo: Civilization): Boolean {
return civInfo.espionageManager.spyList.any { it.getLocation() == city }
return civInfo.espionageManager.spyList.any { it.getCityOrNull() == city }
}
private fun getAllStationedSpies(): List<Spy> {
@ -35,13 +35,12 @@ class CityEspionageManager : IsPartOfGameInfoSerialization {
fun removeAllPresentSpies(reason: SpyFleeReason) {
for (spy in getAllStationedSpies()) {
val owningCiv = spy.civInfo
val notificationString = when (reason) {
SpyFleeReason.CityDestroyed -> "After the city of [${city.name}] was destroyed, your spy [${spy.name}] has fled back to our hideout."
SpyFleeReason.CityCaptured -> "After the city of [${city.name}] was conquered, your spy [${spy.name}] has fled back to our hideout."
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.addNotification(notificationString)
spy.moveTo(null)
}
}

View File

@ -12,6 +12,7 @@ import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen
import com.unciv.ui.screens.diplomacyscreen.DiplomacyScreen
import com.unciv.ui.screens.overviewscreen.EmpireOverviewCategories
import com.unciv.ui.screens.overviewscreen.EmpireOverviewScreen
import com.unciv.ui.screens.overviewscreen.EspionageOverviewScreen
import com.unciv.ui.screens.pickerscreens.PolicyPickerScreen
import com.unciv.ui.screens.pickerscreens.PromotionPickerScreen
import com.unciv.ui.screens.pickerscreens.TechPickerScreen
@ -165,6 +166,18 @@ class PolicyAction(
}
}
/** Open [EspionageOverviewScreen] */
class EspionageAction : NotificationAction {
override fun execute(worldScreen: WorldScreen) {
worldScreen.game.pushScreen(EspionageOverviewScreen(worldScreen.selectedCiv, worldScreen))
}
companion object {
fun withLocation(location: Vector2?): Sequence<NotificationAction> =
LocationAction(location) + EspionageAction()
}
}
@Suppress("PrivatePropertyName") // These names *must* match their class name, see below
internal class NotificationActionsDeserializer {
/* This exists as trick to leverage readFields for Json deserialization.
@ -187,12 +200,14 @@ internal class NotificationActionsDeserializer {
private val PromoteUnitAction: PromoteUnitAction? = null
private val OverviewAction: OverviewAction? = null
private val PolicyAction: PolicyAction? = null
private val EspionageAction: EspionageAction? = null
fun read(json: Json, jsonData: JsonValue): List<NotificationAction> {
json.readFields(this, jsonData)
return listOfNotNull(
LocationAction, TechAction, CityAction, DiplomacyAction, MayaLongCountAction,
MapUnitAction, CivilopediaAction, PromoteUnitAction, OverviewAction, PolicyAction
MapUnitAction, CivilopediaAction, PromoteUnitAction, OverviewAction, PolicyAction,
EspionageAction
)
}
}

View File

@ -45,8 +45,7 @@ class EspionageManager : IsPartOfGameInfoSerialization {
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
return validSpyNames.random()
return validSpyNames.randomOrNull() ?: "Spy ${spyList.size+1}" // +1 as non-programmers count from 1
}
fun addSpy(): Spy {
@ -60,7 +59,7 @@ class EspionageManager : IsPartOfGameInfoSerialization {
fun getTilesVisibleViaSpies(): Sequence<Tile> {
return spyList.asSequence()
.filter { it.isSetUp() }
.mapNotNull { it.getLocation() }
.mapNotNull { it.getCityOrNull() }
.flatMap { it.getCenterTile().getTilesInDistance(1) }
}
@ -74,23 +73,31 @@ class EspionageManager : IsPartOfGameInfoSerialization {
return techsToSteal
}
fun getSpiesInCity(city: City): MutableList<Spy> {
return spyList.filter { it.getLocation() == city }.toMutableList()
fun getSpiesInCity(city: City): List<Spy> {
return spyList.filterTo(mutableListOf()) { it.getCityOrNull() == city }
}
fun getStartingSpyRank(): Int = 1 + civInfo.getMatchingUniques(UniqueType.SpyStartingLevel).sumOf { it.params[0].toInt() }
/**
* Returns a list of all cities with our spies in them.
* The list needs to be stable accross calls on the same turn.
* The list needs to be stable across calls on the same turn.
*/
fun getCitiesWithOurSpies(): List<City> = spyList.filter { it.isSetUp() }.mapNotNull { it.getLocation() }
fun getCitiesWithOurSpies(): List<City> = spyList.filter { it.isSetUp() }.mapNotNull { it.getCityOrNull() }
fun getSpyAssignedToCity(city: City): Spy? = spyList.firstOrNull {it.getLocation() == city}
fun getSpyAssignedToCity(city: City): Spy? = spyList.firstOrNull { it.getCityOrNull() == city }
/**
* Determines whether the NextTurnAction MoveSpies should be shown or not
* @return true if there are spies waiting to be moved
*/
fun shouldShowMoveSpies(): Boolean = !dismissedShouldMoveSpies && spyList.any { it.isIdle() }
fun shouldShowMoveSpies(): Boolean = !dismissedShouldMoveSpies && hasIdleSpies()
/** Are any spies in the hideout?
* @see shouldShowMoveSpies */
fun hasIdleSpies() = spyList.any { it.isIdle() }
fun getIdleSpies(): List<Spy> {
return spyList.filterTo(mutableListOf()) { it.isIdle() }
}
}

View File

@ -5,6 +5,7 @@ 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.EspionageAction
import com.unciv.logic.civilization.NotificationCategory
import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers
@ -15,35 +16,50 @@ import com.unciv.models.ruleset.unique.UniqueType
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"),
Dead("Dead")
enum class SpyAction(val displayString: String, val hasTurns: Boolean, internal val isSetUp: Boolean, private val isDoingWork: Boolean = false) {
None("None", false, false),
Moving("Moving", true, false, true),
EstablishNetwork("Establishing Network", true, false, true),
Surveillance("Observing City", false, true),
StealingTech("Stealing Tech", false, true, true),
RiggingElections("Rigging Elections", true, true) {
override fun isDoingWork(spy: Spy) = !spy.civInfo.isAtWarWith(spy.getCity().civ)
},
CounterIntelligence("Conducting Counter-intelligence", false, true) {
override fun isDoingWork(spy: Spy) = spy.turnsRemainingForAction > 0
},
Dead("Dead", true, false),
;
internal open fun isDoingWork(spy: Spy) = isDoingWork
}
class Spy() : IsPartOfGameInfoSerialization {
// `location == null` means that the spy is in its hideout
private var location: Vector2? = null
class Spy private constructor() : IsPartOfGameInfoSerialization {
lateinit var name: String
var action = SpyAction.None
private set
var rank: Int = 1
private set
// `location == null` means that the spy is in its hideout
private var location: Vector2? = null
var action = SpyAction.None
private set
var turnsRemainingForAction = 0
private set
private var progressTowardsStealingTech = 0
@Transient
lateinit var civInfo: Civilization
private set
@Transient
private lateinit var espionageManager: EspionageManager
@Transient
private var city: City? = null
constructor(name: String, rank:Int) : this() {
this.name = name
this.rank = rank
@ -63,49 +79,49 @@ class Spy() : IsPartOfGameInfoSerialization {
this.espionageManager = civInfo.espionageManager
}
private 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
}
fun endTurn() {
if (action.hasTurns && --turnsRemainingForAction > 0) return
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
if (getCity().civ == civInfo)
// Your own cities are certainly familiar surroundings, so skip establishing a network
setAction(SpyAction.CounterIntelligence, 10)
else
// Should depend on cultural familiarity level if that is ever implemented inter-civ
setAction(SpyAction.EstablishNetwork, 3)
}
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
turnsRemainingForAction = 10
} else if (location.civ == civInfo) {
action = SpyAction.CounterIntelligence
turnsRemainingForAction = 10
} else {
val city = getCity() // This should never throw an exception, as going to the hideout sets your action to None.
if (city.civ.isCityState())
setAction(SpyAction.RiggingElections, 10)
else if (city.civ == civInfo)
setAction(SpyAction.CounterIntelligence, 10)
else
startStealingTech()
}
}
SpyAction.Surveillance -> {
if (!getLocation()!!.civ.isMajorCiv()) return
if (!getCity().civ.isMajorCiv()) return
val stealableTechs = espionageManager.getTechsToSteal(getLocation()!!.civ)
val stealableTechs = espionageManager.getTechsToSteal(getCity().civ)
if (stealableTechs.isEmpty()) return
action = SpyAction.StealingTech // There are new techs to steal!
setAction(SpyAction.StealingTech) // There are new techs to steal!
}
SpyAction.StealingTech -> {
val stealableTechs = espionageManager.getTechsToSteal(getLocation()!!.civ)
val stealableTechs = espionageManager.getTechsToSteal(getCity().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)
setAction(SpyAction.Surveillance)
addNotification("Your spy [$name] cannot steal any more techs from [${getCity().civ}] as we've already researched all the technology they know!")
return
}
val techStealCost = stealableTechs.maxOfOrNull { civInfo.gameInfo.ruleset.technologies[it]!!.cost }!!
var progressThisTurn = getLocation()!!.cityStats.currentCityStats.science
var progressThisTurn = getCity().cityStats.currentCityStats.science
// 33% spy bonus for each level
progressThisTurn *= (rank + 2f) / 3f
progressThisTurn *= getEfficiencyModifier().toFloat()
@ -115,80 +131,71 @@ class Spy() : IsPartOfGameInfoSerialization {
}
}
SpyAction.RiggingElections -> {
--turnsRemainingForAction
if (turnsRemainingForAction > 0) return
rigElection()
}
SpyAction.Dead -> {
--turnsRemainingForAction
if (turnsRemainingForAction > 0) return
val oldSpyName = name
name = espionageManager.getSpyName()
action = SpyAction.None
setAction(SpyAction.None)
rank = espionageManager.getStartingSpyRank()
civInfo.addNotification("We have recruited a new spy name [$name] after [$oldSpyName] was killed.",
NotificationCategory.Espionage, NotificationIcon.Spy)
addNotification("We have recruited a new spy name [$name] after [$oldSpyName] was killed.")
}
SpyAction.CounterIntelligence -> {
// Counter inteligence spies don't do anything here
// Counter intelligence 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
// Once turnsRemainingForAction 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
}
}
fun startStealingTech() {
action = SpyAction.StealingTech
turnsRemainingForAction = 0
private fun startStealingTech() {
setAction(SpyAction.StealingTech)
progressTowardsStealingTech = 0
}
private fun stealTech() {
val city = getLocation()!!
val city = getCity()
val otherCiv = city.civ
val randomSeed = city.location.x * city.location.y + 123f * civInfo.gameInfo.turns
val randomSeed = randomSeed()
val stolenTech = espionageManager.getTechsToSteal(getCity().civ)
.randomOrNull(Random(randomSeed)) // Could be improved to for example steal the most expensive tech or the tech that has the least progress as of yet
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)
}
// Lower is better
var spyResult = Random(randomSeed.toInt()).nextInt(300)
var spyResult = Random(randomSeed).nextInt(300)
// Add our spies experience
spyResult -= getSkillModifier()
// Subtract the experience of the counter inteligence spies
// Subtract the experience of the counter intelligence spies
val defendingSpy = city.civ.espionageManager.getSpyAssignedToCity(city)
spyResult += defendingSpy?.getSkillModifier() ?: 0
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
spyResult >= 200 -> { // The spy was killed in the attempt (should be able to happen even if there's nothing to steal?)
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]!"
}
stolenTech == null -> null // Nothing to steal
spyResult < 0 -> null // Not detected
spyResult < 100 -> "An unidentified spy stole the Technology [$stolenTech] from [$city]!"
else -> "A spy from [${civInfo.civName}] stole the Technology [$stolenTech] from [$city]!"
}
if (detectionString != null)
// Not using Spy.addNotification, shouldn't open the espionage screen
otherCiv.addNotification(detectionString, 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()
if (spyResult < 200 && stolenTech != null) {
civInfo.tech.addTechnology(stolenTech)
addNotification("Your spy [$name] stole the Technology [$stolenTech] from [$city]!")
levelUpSpy()
} else {
civInfo.addNotification("Your spy [$name] was killed trying to steal Technology in [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
}
if (spyResult >= 200) {
addNotification("Your spy [$name] was killed trying to steal Technology in [$city]!")
defendingSpy?.levelUpSpy()
killSpy()
}
} else startStealingTech() // reset progress
if (spyResult >= 100) {
otherCiv.getDiplomacyManager(civInfo).addModifier(DiplomaticModifiers.SpiedOnUs, -15f)
@ -196,25 +203,23 @@ class Spy() : IsPartOfGameInfoSerialization {
}
private fun rigElection() {
val city = getLocation()!!
val city = getCity()
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()!!)
val defendingSpy = allyCiv.espionageManager.getSpyAssignedToCity(city)
if (defendingSpy != null) {
val randomSeed = city.location.x * city.location.y + 123f * civInfo.gameInfo.turns
var spyResult = Random(randomSeed.toInt()).nextInt(120)
var spyResult = Random(randomSeed()).nextInt(120)
spyResult -= getSkillModifier()
spyResult += defendingSpy.getSkillModifier()
if (spyResult > 100) {
// The Spy was killed
// The Spy was killed (use the notification without EspionageAction)
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)
city.location, NotificationCategory.Espionage, NotificationIcon.Spy)
addNotification("Your spy [$name] was killed trying to rig the election in [$city]!")
killSpy()
defendingSpy.levelUpSpy()
return
@ -222,7 +227,7 @@ class Spy() : IsPartOfGameInfoSerialization {
}
}
// Starts at 10 influence and increases by 3 for each extra rank.
cityStateCiv.getDiplomacyManager(civInfo).addInfluence(7f + getSpyRank() * 3)
cityStateCiv.getDiplomacyManager(civInfo).addInfluence(7f + rank * 3)
civInfo.addNotification("Your spy successfully rigged the election in [$city]!", city.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
}
@ -230,81 +235,82 @@ class Spy() : IsPartOfGameInfoSerialization {
fun moveTo(city: City?) {
if (city == null) { // Moving to spy hideout
location = null
action = SpyAction.None
turnsRemainingForAction = 0
this.city = null
setAction(SpyAction.None)
return
}
location = city.location
action = SpyAction.Moving
turnsRemainingForAction = 1
this.city = city
setAction(SpyAction.Moving, 1)
}
fun canMoveTo(city: City): Boolean {
if (getLocation() == city) return true
if (getCityOrNull() == city) return true
if (!city.getCenterTile().isExplored(civInfo)) return false
return espionageManager.getSpyAssignedToCity(city) == null
}
fun isSetUp() = action !in listOf(SpyAction.Moving, SpyAction.None, SpyAction.EstablishNetwork)
fun isSetUp() = action.isSetUp
fun isIdle(): Boolean =action == SpyAction.None || action == SpyAction.Surveillance
fun isIdle() = action == SpyAction.None
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 isDoingWork() = action.isDoingWork(this)
fun getLocation(): City? {
/** Returns the City this Spy is in, or `null` if it is in the hideout. */
fun getCityOrNull(): City? {
if (location == null) return null
return civInfo.gameInfo.tileMap[location!!].getCity()
if (city == null) city = civInfo.gameInfo.tileMap[location!!].getCity()
return city
}
fun getLocationName(): String {
return getLocation()?.name ?: Constants.spyHideout
}
/** Non-null version of [getCityOrNull] for the frequent case it is known the spy cannot be in the hideout.
* @throws NullPointerException if the spy is in the hideout */
fun getCity(): City = getCityOrNull()!!
fun getSpyRank(): Int {
return rank
}
fun getLocationName() = getCityOrNull()?.name ?: Constants.spyHideout
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)
}
addNotification("Your spy [$name] has leveled up!")
rank++
}
fun getSkillModifier(): Int {
return getSpyRank() * 30
}
/** Zero-based modifier expressing shift of probabilities from Spy Rank
*
* 100 units change one step in results, there are 4 such steps, and the default random spans 300 units and excludes the best result (undetected success).
* Thus the return value translates into (return / 3) percent chance to get the very best result, reducing the chance to get the worst result (kill) by the same amount.
* The same modifier from defending counter-intelligence spies goes linearly in the opposite direction.
* With the range of this function being hardcoded to 30..90 (and 0 for no defensive spy present), ranks cannot guarantee either best or worst outcome.
* Or - chance range of best result is 0% (rank 1 vs rank 3 defender) to 30% (rank 3 vs no defender), range of worst is 53% to 3%, respectively.
*/
// Todo Moddable as some global and/or in-game-gainable Uniques?
private fun getSkillModifier() = rank * 30
/**
* Gets a friendly and enemy efficiency uniques for the spy at the location
* @return a value centered around 100 for the work efficiency of the spy, won't be negative
* @return a value centered around 1.0 for the work efficiency of the spy, won't be negative
*/
fun getEfficiencyModifier(): Double {
lateinit var friendlyUniques: Sequence<Unique>
lateinit var enemyUniques: Sequence<Unique>
if (getLocation() != null) {
val city = getLocation()!!
if (city.civ == civInfo) {
val friendlyUniques: Sequence<Unique>
val enemyUniques: Sequence<Unique>
val city = getCityOrNull()
when {
city == null -> {
// Spy is in hideout - effectiveness won't matter
friendlyUniques = civInfo.getMatchingUniques(UniqueType.SpyEffectiveness)
enemyUniques = sequenceOf()
}
city.civ == civInfo -> {
// Spy is in our own city
friendlyUniques = city.getMatchingUniques(UniqueType.SpyEffectiveness, StateForConditionals(city), includeCivUniques = true)
enemyUniques = sequenceOf()
} else {
}
else -> {
// Spy is active in a foreign city
friendlyUniques = civInfo.getMatchingUniques(UniqueType.SpyEffectiveness)
enemyUniques = city.getMatchingUniques(UniqueType.EnemySpyEffectiveness, StateForConditionals(city), includeCivUniques = true)
}
} else {
friendlyUniques = civInfo.getMatchingUniques(UniqueType.SpyEffectiveness)
enemyUniques = sequenceOf()
}
var totalEfficiency = 1.0
totalEfficiency *= (100.0 + friendlyUniques.sumOf { it.params[0].toInt() }) / 100
@ -312,13 +318,20 @@ class Spy() : IsPartOfGameInfoSerialization {
return totalEfficiency.coerceAtLeast(0.0)
}
fun killSpy() {
private 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
setAction(SpyAction.Dead, 5)
rank = 1
}
fun isAlive(): Boolean = action != SpyAction.Dead
/** Shorthand for [Civilization.addNotification] specialized for espionage - action, category and icon are always the same */
fun addNotification(text: String) =
civInfo.addNotification(text, EspionageAction.withLocation(location), NotificationCategory.Espionage, NotificationIcon.Spy)
/** Anti-save-scum: Deterministic random from city and turn
* @throws NullPointerException for spies in the hideout */
private fun randomSeed() = (getCity().run { location.x * location.y } + 123f * civInfo.gameInfo.turns).toInt()
}

View File

@ -808,17 +808,13 @@ object UniqueTriggerActivation {
val currentEra = civInfo.getEra().name
for (otherCiv in civInfo.gameInfo.getAliveMajorCivs()) {
if (currentEra !in otherCiv.espionageManager.erasSpyEarnedFor) {
val spyName = otherCiv.espionageManager.addSpy().name
val spy = otherCiv.espionageManager.addSpy()
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.Espionage, NotificationIcon.Spy)
spy.addNotification("We have recruited [${spy.name}] as a spy!")
else
otherCiv.addNotification(
"After an unknown civilization entered the [${currentEra}], we have recruited [${spyName}] as a spy!",
NotificationCategory.Espionage,
NotificationIcon.Spy
)
spy.addNotification("After an unknown civilization entered the [$currentEra], we have recruited [${spy.name}] as a spy!")
}
}
true
@ -840,8 +836,8 @@ object UniqueTriggerActivation {
if (!civInfo.gameInfo.isEspionageEnabled()) return null
return {
val spyName = civInfo.espionageManager.addSpy().name
civInfo.addNotification("We have recruited [${spyName}] as a spy!", NotificationCategory.Espionage, NotificationIcon.Spy)
val spy = civInfo.espionageManager.addSpy()
spy.addNotification("We have recruited [${spy.name}] as a spy!")
true
}
}

View File

@ -228,8 +228,8 @@ enum class UniqueType(
FaithCostOfGreatProphetChange("[relativeAmount]% Faith cost of generating Great Prophet equivalents", UniqueTarget.Global),
/// Espionage
SpyEffectiveness("[relativeAmount]% spy effectiveness [cityFilter]", UniqueTarget.Global, UniqueTarget.Global),
EnemySpyEffectiveness("[relativeAmount]% enemy spy effectiveness [cityFilter]", UniqueTarget.Global, UniqueTarget.Global),
SpyEffectiveness("[relativeAmount]% spy effectiveness [cityFilter]", UniqueTarget.Global),
EnemySpyEffectiveness("[relativeAmount]% enemy spy effectiveness [cityFilter]", UniqueTarget.Global),
SpyStartingLevel("New spies start with [amount] level(s)", UniqueTarget.Global),
/// Things you get at the start of the game

View File

@ -0,0 +1,43 @@
package com.unciv.ui.components
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.NinePatch
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
class SmallButtonStyle : TextButton.TextButtonStyle(BaseScreen.skin[TextButton.TextButtonStyle::class.java]) {
/** Modify NinePatch geometry so the roundedEdgeRectangleMidShape button is 38f high instead of 48f,
* Otherwise this excercise would be futile - normal roundedEdgeRectangleShape based buttons are 50f high.
*/
private fun NinePatchDrawable.reduce(): NinePatchDrawable {
val patch = NinePatch(this.patch)
patch.padTop = 10f
patch.padBottom = 10f
patch.topHeight = 10f
patch.bottomHeight = 10f
return NinePatchDrawable(this).also { it.patch = patch }
}
init {
val upColor = BaseScreen.skin.getColor("color")
val downColor = BaseScreen.skin.getColor("pressed")
val overColor = BaseScreen.skin.getColor("highlight")
val disabledColor = BaseScreen.skin.getColor("disabled")
// UiElementDocsWriter inspects source, which is why this isn't prettified better
val shape = BaseScreen.run {
// Let's use _one_ skinnable background lookup but with different tints
val skinned = skinStrings.getUiBackground("AnimatedMenu/Button", skinStrings.roundedEdgeRectangleMidShape)
// Reduce height only if not skinned
val default = ImageGetter.getNinePatch(skinStrings.roundedEdgeRectangleMidShape)
if (skinned === default) default.reduce() else skinned
}
// Now get the tinted variants
up = shape.tint(upColor)
down = shape.tint(downColor)
over = shape.tint(overColor)
disabled = shape.tint(disabledColor)
disabledFontColor = Color.GRAY
}
}

View File

@ -1,7 +1,6 @@
package com.unciv.ui.popups
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.g2d.NinePatch
import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
@ -10,14 +9,13 @@ import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.badlogic.gdx.scenes.scene2d.ui.Container
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.badlogic.gdx.scenes.scene2d.utils.NinePatchDrawable
import com.unciv.ui.components.SmallButtonStyle
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.utils.Concurrency
@ -163,40 +161,4 @@ open class AnimatedMenuPopup(
close()
}
}
//todo Reused in SpecialistAllocationTable - refactor to another package
class SmallButtonStyle : TextButton.TextButtonStyle(BaseScreen.skin[TextButton.TextButtonStyle::class.java]) {
/** Modify NinePatch geometry so the roundedEdgeRectangleMidShape button is 38f high instead of 48f,
* Otherwise this excercise would be futile - normal roundedEdgeRectangleShape based buttons are 50f high.
*/
private fun NinePatchDrawable.reduce(): NinePatchDrawable {
val patch = NinePatch(this.patch)
patch.padTop = 10f
patch.padBottom = 10f
patch.topHeight = 10f
patch.bottomHeight = 10f
return NinePatchDrawable(this).also { it.patch = patch }
}
init {
val upColor = BaseScreen.skin.getColor("color")
val downColor = BaseScreen.skin.getColor("pressed")
val overColor = BaseScreen.skin.getColor("highlight")
val disabledColor = BaseScreen.skin.getColor("disabled")
// UiElementDocsWriter inspects source, which is why this isn't prettified better
val shape = BaseScreen.run {
// Let's use _one_ skinnable background lookup but with different tints
val skinned = skinStrings.getUiBackground("AnimatedMenu/Button", skinStrings.roundedEdgeRectangleMidShape)
// Reduce height only if not skinned
val default = ImageGetter.getNinePatch(skinStrings.roundedEdgeRectangleMidShape)
if (skinned === default) default.reduce() else skinned
}
// Now get the tinted variants
up = shape.tint(upColor)
down = shape.tint(downColor)
over = shape.tint(overColor)
disabled = shape.tint(disabledColor)
disabledFontColor = Color.GRAY
}
}
}

View File

@ -5,6 +5,7 @@ import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.ui.components.SmallButtonStyle
import com.unciv.ui.components.UncivTooltip.Companion.addTooltip
import com.unciv.ui.components.extensions.addSeparatorVertical
import com.unciv.ui.components.extensions.darken
@ -16,15 +17,14 @@ import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.ExpanderTab
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.AnimatedMenuPopup
import com.unciv.ui.screens.basescreen.BaseScreen
class SpecialistAllocationTable(private val cityScreen: CityScreen) : Table(BaseScreen.skin) {
val city = cityScreen.city
private val smallButtonStyle = AnimatedMenuPopup.SmallButtonStyle()
private val smallButtonStyle = SmallButtonStyle()
fun update() {
// 5 columns: "-" unassignButton, AllocationTable, "+" assignButton, SeparatorVertical, SpecialistsStatsTabe
// 5 columns: "-" unassignButton, AllocationTable, "+" assignButton, SeparatorVertical, SpecialistsStatsTable
clear()
// Auto/Manual Specialists Toggle

View File

@ -19,6 +19,7 @@ import com.unciv.models.metadata.Player
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.nation.Nation
import com.unciv.models.translations.tr
import com.unciv.ui.components.SmallButtonStyle
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.enable
import com.unciv.ui.components.extensions.pad
@ -28,7 +29,6 @@ import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onChange
import com.unciv.ui.components.widgets.LoadingImage
import com.unciv.ui.popups.AnimatedMenuPopup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.victoryscreen.LoadMapPreview
import com.unciv.utils.Concurrency
@ -53,7 +53,7 @@ class MapFileSelectTable(
private val mapCategorySelectBox = SelectBox<String>(BaseScreen.skin)
private val mapFileSelectBox = SelectBox<MapWrapper>(BaseScreen.skin)
private val loadingIcon = LoadingImage(30f, LoadingImage.Style(loadingColor = Color.SCARLET))
private val useNationsFromMapButton = "Select players from starting locations".toTextButton(AnimatedMenuPopup.SmallButtonStyle())
private val useNationsFromMapButton = "Select players from starting locations".toTextButton(SmallButtonStyle())
private val useNationsButtonCell: Cell<Actor?>
private var mapNations = emptyList<Nation>()
private var mapHumanPick: String? = null

View File

@ -10,8 +10,8 @@ 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
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.setSize
@ -41,7 +41,10 @@ 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<Button, City?>()
private var moveSpyHereButtons = hashMapOf<MoveToCityButton, City?>()
/** Readability shortcut */
private val manager get() = civInfo.espionageManager
init {
spySelectionTable.defaults().pad(10f)
@ -73,15 +76,12 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
spySelectionTable.add("Rank".toLabel())
spySelectionTable.add("Location".toLabel())
spySelectionTable.add("Action".toLabel()).row()
for (spy in civInfo.espionageManager.spyList) {
for (spy in manager.spyList) {
spySelectionTable.add(spy.name.toLabel())
spySelectionTable.add(spy.rank.toLabel())
spySelectionTable.add(spy.getLocationName().toLabel())
val actionString =
when (spy.action) {
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}"
}
val actionString = if (spy.action.hasTurns) "[${spy.action.displayString}] ${spy.turnsRemainingForAction}${Fonts.turn}"
else spy.action.displayString
spySelectionTable.add(actionString.toLabel())
val moveSpyButton = "Move".toTextButton()
@ -95,10 +95,14 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
selectedSpy = spy
selectedSpyButton!!.label.setText(Constants.cancel.tr())
for ((button, city) in moveSpyHereButtons) {
// Not own cities as counterintelligence isn't implemented
// Not city-state civs as rigging elections isn't implemented
if (city == spy.getCityOrNull()) {
button.isVisible = true
button.setDirection(Align.right)
} else {
button.isVisible = city == null // hideout
|| (city.civ != civInfo && !city.espionage.hasSpyOf(civInfo))
|| !city.espionage.hasSpyOf(civInfo)
button.setDirection(Align.left)
}
}
}
if (!worldScreen.canChangeState || !spy.isAlive()) {
@ -113,15 +117,15 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
citySelectionTable.clear()
moveSpyHereButtons.clear()
citySelectionTable.add()
citySelectionTable.add("City".toLabel())
citySelectionTable.add("Spy present".toLabel()).row()
citySelectionTable.add("City".toLabel()).padTop(10f)
citySelectionTable.add("Spy present".toLabel()).padTop(10f).row()
// First add the hideout to the table
citySelectionTable.add()
citySelectionTable.add("Spy Hideout".toLabel())
citySelectionTable.add()
val moveSpyHereButton = getMoveToCityButton(null)
citySelectionTable.add(getSpyIcons(manager.getIdleSpies()))
val moveSpyHereButton = MoveToCityButton(null)
citySelectionTable.add(moveSpyHereButton).row()
// Then add all cities
@ -147,41 +151,70 @@ class EspionageOverviewScreen(val civInfo: Civilization, val worldScreen: WorldS
private fun addCityToSelectionTable(city: City) {
citySelectionTable.add(ImageGetter.getNationPortrait(city.civ.nation, 30f))
.padLeft(20f)
citySelectionTable.add(city.name.toLabel(hideIcons = true))
if (city.espionage.hasSpyOf(civInfo)) {
citySelectionTable.add(
ImageGetter.getImage("OtherIcons/Spy_White").apply {
setSize(30f)
color = Color.WHITE
}
)
} else {
citySelectionTable.add()
val label = city.name.toLabel(hideIcons = true)
label.onClick {
worldScreen.game.popScreen() // If a detour to this screen (i.e. not directly from worldScreen) is made possible, use resetToWorldScreen instead
worldScreen.mapHolder.setCenterPosition(city.location)
}
citySelectionTable.add(label).fill()
citySelectionTable.add(getSpyIcons(manager.getSpiesInCity(city)))
val moveSpyHereButton = getMoveToCityButton(city)
val moveSpyHereButton = MoveToCityButton(city)
citySelectionTable.add(moveSpyHereButton)
citySelectionTable.row()
}
private fun getSpyIcon(spy: Spy) = Table().apply {
add (ImageGetter.getImage("OtherIcons/Spy_White").apply {
color = Color.WHITE
}).size(30f)
val color = when(spy.rank) {
1 -> Color.BROWN
2 -> Color.LIGHT_GRAY
3 -> Color.GOLD
else -> return@apply
}
val starTable = Table()
repeat(spy.rank) {
val star = ImageGetter.getImage("OtherIcons/Star")
star.color = color
starTable.add(star).size(8f).pad(1f).row()
}
add(starTable).center().padLeft(-4f)
}
private fun getSpyIcons(spies: Iterable<Spy>) = Table().apply {
defaults().space(0f, 2f, 0f, 2f)
for (spy in spies)
add(getSpyIcon(spy))
}
// city == null is interpreted as 'spy hideout'
private fun getMoveToCityButton(city: City?): Button {
val moveSpyHereButton = Button(skin)
moveSpyHereButton.add(ImageGetter.getArrowImage(Align.left).apply { color = Color.WHITE })
moveSpyHereButton.onClick {
private inner class MoveToCityButton(city: City?) : Button(SmallButtonStyle()) {
val arrow = ImageGetter.getArrowImage(Align.left)
init {
arrow.setSize(24f)
add(arrow).size(24f)
arrow.setOrigin(Align.center)
arrow.color = Color.WHITE
onClick {
selectedSpy!!.moveTo(city)
resetSelection()
update()
}
moveSpyHereButtons[moveSpyHereButton] = city
moveSpyHereButton.isVisible = false
return moveSpyHereButton
moveSpyHereButtons[this] = city
isVisible = false
}
fun setDirection(align: Int) {
arrow.rotation = if (align == Align.right) 0f else 180f
isDisabled = align == Align.right
}
}
private fun resetSelection() {
selectedSpy = null
if (selectedSpyButton != null)
selectedSpyButton!!.label.setText("Move".tr())
selectedSpyButton?.label?.setText("Move".tr())
selectedSpyButton = null
for ((button, _) in moveSpyHereButtons)
button.isVisible = false