Espionage Uniques, Buildings and Policy (#11401)

* Added OneTimeSpiesLevelUp, OneTimeGainSpy, SpyEffectiveness, EnemySpyEffectiveness and HiddenWithoutEspionage Uniques

* Spy effectiveness affects stealing tech and rigging elections

* Fixed HiddenWithoutEspionage

* Added Constabulary and Police Station

* Added cityFilter to SpyEffectiveness

* Added national Intelligence agency

* Added Great Firewall

* Fixed great firewall having a float value

* EspionageManager addSpy now returns Spy instead of name

* Added some simple espionage tests

* Fixed OneTimeSpiesLevelUp still wanting parameter

* Spy efficiency occurs after skill modifier

* Added another test

* Added Police State spy efficiency reduction unique

* Fixed "Hidden when espionage is disabled" wording

* Fixed "effectiveness" wording

* Changed "enemy spy effectiveness" unique to use negative matters

* Spy effectiveness only affect tech steal rate

* Changed "Gain an extra spy" and "Promotes all spies" uniques

* Removed Police State comment that is no longer accurate

* Changed spy effectiveness to be multiplicative
This commit is contained in:
Oskar Niesen 2024-04-09 15:12:21 -05:00 committed by GitHub
parent 35c3f7b191
commit ef9965e218
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 203 additions and 18 deletions

View File

@ -709,6 +709,14 @@
"requiredBuilding": "Market",
"requiredTech": "Banking"
},
{
"name": "Constabulary",
"cost": 160,
"maintenance": 1,
"hurryCostModifier": 10,
"uniques": ["Hidden when espionage is disabled", "[-25]% enemy spy effectiveness [in this city]"],
"requiredTech": "Banking"
},
// will be introduced in BNW expansion pack
// {
// "name": "Hanse",
@ -934,6 +942,15 @@
"uniques": ["Must be on [River]","[+1 Production] from [River] tiles [in this city]"],
"requiredTech": "Electricity"
},
{
"name": "Police Station",
"cost": 300,
"maintenance": 1,
"hurryCostModifier": 10,
"uniques": ["Hidden when espionage is disabled", "[-25]% enemy spy effectiveness [in this city]"],
"requiredBuilding": "Constabulary",
"requiredTech": "Electricity"
},
// Modern Era
@ -964,6 +981,15 @@
"requiredTech": "Radio",
"quote": "'We live only to discover beauty, all else is a form of waiting' - Kahlil Gibran"
},
{
"name": "National Intelligence Agency",
"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]",
"Only available <if [Police Station] is constructed in all [non-[Puppeted]] cities>", "Cost increases by [30] per owned city"],
"requiredTech": "Radio"
},
{
"name": "Military Base",
"cityStrength": 12,
@ -1083,6 +1109,13 @@
"Hidden when [Scientific] Victory is disabled", "Cannot be hurried"],
"requiredTech": "Rocketry"
},
{
"name": "Great Firewall",
"isWonder": true,
"uniques": ["Hidden when espionage is disabled", "[-99]% enemy spy effectiveness [in this city]",
"[-25]% enemy spy effectiveness [in all cities]",],
"requiredTech": "Computers"
},
// Information Era

View File

@ -577,9 +577,9 @@
"name": "Police State",
"uniques": [
"[+3 Happiness] from every [Courthouse]",
"[+100]% Production when constructing [Courthouse] buildings [in all cities]"
"[+100]% Production when constructing [Courthouse] buildings [in all cities]",
"[-25]% enemy spy effectiveness [in all cities]"
],
// There are also some uniques regarding espoinage, which as of this writing is not yet implemented
"requires": ["Militarism"],
"row": 2,
"column": 4

View File

@ -41,12 +41,12 @@ class EspionageManager : IsPartOfGameInfoSerialization {
return validSpyNames.random()
}
fun addSpy(): String {
fun addSpy(): Spy {
val spyName = getSpyName()
val newSpy = Spy(spyName)
newSpy.setTransients(civInfo)
spyList.add(newSpy)
return spyName
return newSpy
}
fun getTilesVisibleViaSpies(): Sequence<Tile> {

View File

@ -8,6 +8,9 @@ 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 com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import kotlin.random.Random
@ -100,8 +103,10 @@ class Spy() : IsPartOfGameInfoSerialization {
return
}
val techStealCost = stealableTechs.maxOfOrNull { civInfo.gameInfo.ruleset.technologies[it]!!.cost }!!
var progressThisTurn = getLocation()!!.cityStats.currentCityStats.science
// 33% spy bonus for each level
val progressThisTurn = getLocation()!!.cityStats.currentCityStats.science * (rank + 2f) / 3f
progressThisTurn *= (rank + 2f) / 3f
progressThisTurn *= getEfficiencyModifier().toFloat()
progressTowardsStealingTech += progressThisTurn.toInt()
if (progressTowardsStealingTech > techStealCost) {
stealTech()
@ -120,7 +125,7 @@ class Spy() : IsPartOfGameInfoSerialization {
val oldSpyName = name
name = espionageManager.getSpyName()
action = SpyAction.None
civInfo.addNotification("We have recruited a new spy name [$name] after [$oldSpyName] was killed.",
civInfo.addNotification("We have recruited a new spy name [$name] after [$oldSpyName] was killed.",
NotificationCategory.Espionage, NotificationIcon.Spy)
}
SpyAction.CounterIntelligence -> {
@ -129,7 +134,7 @@ class Spy() : IsPartOfGameInfoSerialization {
// 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
}
}
@ -157,7 +162,6 @@ class Spy() : IsPartOfGameInfoSerialization {
// 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 detectionString = when {
spyResult < 0 -> null // Not detected
@ -228,7 +232,7 @@ class Spy() : IsPartOfGameInfoSerialization {
action = SpyAction.Moving
turnsRemainingForAction = 1
}
fun canMoveTo(city: City): Boolean {
if (getLocation() == city) return true
if (!city.getCenterTile().isVisible(civInfo)) return false
@ -238,7 +242,7 @@ class Spy() : IsPartOfGameInfoSerialization {
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(): Boolean {
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
@ -260,9 +264,9 @@ class Spy() : IsPartOfGameInfoSerialization {
fun levelUpSpy() {
//TODO: Make the spy level cap dependent on some unique
if (rank >= 3) return
if (rank >= 3) return
if (getLocation() != null) {
civInfo.addNotification("Your spy [$name] has leveled up!", getLocation()!!.location,
civInfo.addNotification("Your spy [$name] has leveled up!", getLocation()!!.location,
NotificationCategory.Espionage, NotificationIcon.Spy)
} else {
civInfo.addNotification("Your spy [$name] has leveled up!",
@ -275,6 +279,32 @@ class Spy() : IsPartOfGameInfoSerialization {
return getSpyRank() * 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
*/
fun getEfficiencyModifier(): Double {
lateinit var friendlyUniques: Sequence<Unique>
lateinit var enemyUniques: Sequence<Unique>
if (getLocation() != null) {
val city = getLocation()!!
if (city.civ == civInfo) {
friendlyUniques = city.getMatchingUniques(UniqueType.SpyEffectiveness, StateForConditionals(city), includeCivUniques = true)
enemyUniques = sequenceOf()
} else {
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
totalEfficiency *= (100.0 + enemyUniques.sumOf { it.params[0].toInt() }) / 100
return totalEfficiency.coerceAtLeast(0.0)
}
fun killSpy() {
// We don't actually remove this spy object, we set them as dead and let them revive
moveTo(null)
@ -282,6 +312,6 @@ class Spy() : IsPartOfGameInfoSerialization {
turnsRemainingForAction = 5
rank = 1
}
fun isAlive(): Boolean = action != SpyAction.Dead
}

View File

@ -315,6 +315,10 @@ class Building : RulesetStatsObject(), INonPerpetualConstruction {
if (!civ.gameInfo.isReligionEnabled())
yield(RejectionReasonType.DisabledBySetting.toInstance())
UniqueType.HiddenWithoutEspionage ->
if (!civ.gameInfo.isEspionageEnabled())
yield(RejectionReasonType.DisabledBySetting.toInstance())
UniqueType.MaxNumberBuildable ->
if (civ.civConstructions.countConstructedObjects(this@Building) >= unique.params[0].toInt())
yield(RejectionReasonType.MaxNumberBuildable.toInstance())

View File

@ -15,6 +15,7 @@ import com.unciv.ui.objectdescriptions.BaseUnitDescriptions
import com.unciv.ui.objectdescriptions.BuildingDescriptions
import com.unciv.ui.objectdescriptions.ImprovementDescriptions
import com.unciv.ui.objectdescriptions.uniquesToCivilopediaTextLines
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen.Companion.showEspionageInCivilopedia
import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen.Companion.showReligionInCivilopedia
import com.unciv.ui.screens.civilopediascreen.FormattedLine
import kotlin.math.pow
@ -193,11 +194,13 @@ class Nation : RulesetObject() {
private fun getUniqueBuildingsText(ruleset: Ruleset) = sequence {
val religionEnabled = showReligionInCivilopedia(ruleset)
val espionageEnabled = showEspionageInCivilopedia(ruleset)
for (building in ruleset.buildings.values) {
when {
building.uniqueTo != name -> continue
building.hasUnique(UniqueType.HiddenFromCivilopedia) -> continue
!religionEnabled && building.hasUnique(UniqueType.HiddenWithoutReligion) -> continue
!espionageEnabled && building.hasUnique(UniqueType.HiddenWithoutEspionage) -> continue
}
yield(FormattedLine(separator = true))
yield(FormattedLine("{${building.name}} -", link=building.makeLink()))

View File

@ -804,7 +804,7 @@ object UniqueTriggerActivation {
val currentEra = civInfo.getEra().name
for (otherCiv in civInfo.gameInfo.getAliveMajorCivs()) {
if (currentEra !in otherCiv.espionageManager.erasSpyEarnedFor) {
val spyName = otherCiv.espionageManager.addSpy()
val spyName = otherCiv.espionageManager.addSpy().name
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
@ -821,6 +821,26 @@ object UniqueTriggerActivation {
}
}
UniqueType.OneTimeSpiesLevelUp -> {
if (!civInfo.isMajorCiv()) return null
if (!civInfo.gameInfo.isEspionageEnabled()) return null
return {
civInfo.espionageManager.spyList.forEach { it.levelUpSpy() }
true
}
}
UniqueType.OneTimeGainSpy -> {
if (!civInfo.isMajorCiv()) return null
if (!civInfo.gameInfo.isEspionageEnabled()) return null
return {
civInfo.espionageManager.addSpy()
true
}
}
UniqueType.GainFreeBuildings -> {
val freeBuilding = civInfo.getEquivalentBuilding(unique.params[0])
val applicableCities =

View File

@ -227,6 +227,10 @@ enum class UniqueType(
MayNotGenerateGreatProphet("May not generate great prophet equivalents naturally", UniqueTarget.Global),
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),
/// Things you get at the start of the game
StartingTech("Starting tech", UniqueTarget.Tech),
StartsWithTech("Starts with [tech]", UniqueTarget.Nation),
@ -787,6 +791,8 @@ enum class UniqueType(
OneTimeRevealCrudeMap("From a randomly chosen tile [positiveAmount] tiles away from the ruins, reveal tiles up to [positiveAmount] tiles away with [positiveAmount]% chance", UniqueTarget.Ruins),
OneTimeGlobalAlert("Triggers the following global alert: [comment]", UniqueTarget.Triggerable), // used in Policy
OneTimeGlobalSpiesWhenEnteringEra("Every major Civilization gains a spy once a civilization enters this era", UniqueTarget.Era),
OneTimeSpiesLevelUp("Promotes all spies", UniqueTarget.Triggerable), // used in Policies, Buildings
OneTimeGainSpy("Gain an extra spy", UniqueTarget.Triggerable), // used in Wonders
OneTimeUnitHeal("Heal this unit by [positiveAmount] HP", UniqueTarget.UnitTriggerable),
OneTimeUnitDamage("This Unit takes [positiveAmount] damage", UniqueTarget.UnitTriggerable),
@ -853,6 +859,8 @@ enum class UniqueType(
HiddenWithoutReligion("Hidden when religion is disabled",
UniqueTarget.Unit, UniqueTarget.Building, UniqueTarget.Ruins, UniqueTarget.Tutorial,
flags = UniqueFlag.setOfHiddenToUsers),
HiddenWithoutEspionage("Hidden when espionage is disabled", UniqueTarget.Building,
flags = UniqueFlag.setOfHiddenToUsers),
HiddenWithoutVictoryType("Hidden when [victoryType] Victory is disabled", UniqueTarget.Building, UniqueTarget.Unit, flags = UniqueFlag.setOfHiddenToUsers),
HiddenFromCivilopedia("Will not be displayed in Civilopedia", *UniqueTarget.Displayable, flags = UniqueFlag.setOfHiddenToUsers),

View File

@ -309,20 +309,21 @@ object TechnologyDescriptions {
civInfo: Civilization?,
predicate: (Building) -> Boolean
): Sequence<Building> {
val (nuclearWeaponsEnabled, religionEnabled) = getNukeAndReligionSwitches(civInfo)
val (nuclearWeaponsEnabled, religionEnabled, espionageEnabled) = getNukeAndReligionSwitches(civInfo)
return ruleset.buildings.values.asSequence()
.filter {
predicate(it) // expected to be the most selective, thus tested first
&& (it.uniqueTo == civInfo?.civName || it.uniqueTo == null && civInfo?.getEquivalentBuilding(it) == it)
&& (nuclearWeaponsEnabled || !it.hasUnique(UniqueType.EnablesNuclearWeapons))
&& (religionEnabled || !it.hasUnique(UniqueType.HiddenWithoutReligion))
&& (espionageEnabled || !it.hasUnique(UniqueType.HiddenWithoutEspionage))
&& !it.hasUnique(UniqueType.HiddenFromCivilopedia)
}
}
private fun getNukeAndReligionSwitches(civInfo: Civilization?): Pair<Boolean, Boolean> {
if (civInfo == null) return true to true
return civInfo.gameInfo.run { gameParameters.nuclearWeaponsEnabled to isReligionEnabled() }
private fun getNukeAndReligionSwitches(civInfo: Civilization?): Triple<Boolean, Boolean, Boolean> {
if (civInfo == null) return Triple(true, true, true)
return civInfo.gameInfo.run { Triple(gameParameters.nuclearWeaponsEnabled, isReligionEnabled(), isEspionageEnabled()) }
}
/**

View File

@ -200,12 +200,14 @@ class CivilopediaScreen(
val imageSize = 50f
val religionEnabled = showReligionInCivilopedia(ruleset)
val espionageEnabled = showEspionageInCivilopedia(ruleset)
val victoryTypes = game.gameInfo?.gameParameters?.victoryTypes ?: ruleset.victories.keys
fun shouldBeDisplayed(obj: IHasUniques): Boolean {
return when {
obj.hasUnique(UniqueType.HiddenFromCivilopedia) -> false
(!religionEnabled && obj.hasUnique(UniqueType.HiddenWithoutReligion)) -> false
(!espionageEnabled && obj.hasUnique(UniqueType.HiddenWithoutEspionage)) -> false
obj.getMatchingUniques(UniqueType.HiddenWithoutVictoryType).any { !victoryTypes.contains(it.params[0]) } -> false
else -> true
}
@ -326,5 +328,11 @@ class CivilopediaScreen(
ruleset != null -> ruleset.beliefs.isNotEmpty()
else -> true
}
fun showEspionageInCivilopedia(ruleset: Ruleset? = null) = when {
UncivGame.isCurrentInitialized() && UncivGame.Current.gameInfo != null ->
UncivGame.Current.gameInfo!!.isEspionageEnabled()
else -> true
}
}
}

View File

@ -98,6 +98,7 @@ class WonderInfo {
val gameInfo = UncivGame.Current.gameInfo!!
val ruleSet = gameInfo.ruleset
private val hideReligionItems = !gameInfo.isReligionEnabled()
private val hideEspionageItems = !gameInfo.isEspionageEnabled()
private val startingObsolete = ruleSet.eras[gameInfo.gameParameters.startingEra]!!.startingObsoleteWonders
enum class WonderStatus(val label: String) {
@ -150,6 +151,7 @@ class WonderInfo {
private fun shouldBeDisplayed(viewingPlayer: Civilization, wonder: Building, wonderEra: Int?) = when {
wonder.hasUnique(UniqueType.HiddenFromCivilopedia) -> false
wonder.hasUnique(UniqueType.HiddenWithoutReligion) && hideReligionItems -> false
wonder.hasUnique(UniqueType.HiddenWithoutEspionage) && hideEspionageItems -> false
wonder.name in startingObsolete -> false
wonder.getMatchingUniques(UniqueType.HiddenWithoutVictoryType)
.any { unique ->

View File

@ -144,6 +144,12 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
Applicable to: Triggerable
??? example "Promotes all spies"
Applicable to: Triggerable
??? example "Gain an extra spy"
Applicable to: Triggerable
??? example "Turn this tile into a [terrainName] tile"
Example: "Turn this tile into a [Forest] tile"
@ -758,6 +764,16 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
Applicable to: Global
??? example "[relativeAmount]% spy effectiveness [cityFilter]"
Example: "[+20]% spy effectiveness [in all cities]"
Applicable to: Global
??? example "[relativeAmount]% enemy spy effectiveness [cityFilter]"
Example: "[+20]% enemy spy effectiveness [in all cities]"
Applicable to: Global
??? example "Triggers victory"
Applicable to: Global
@ -1176,6 +1192,9 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
??? example "Hidden when religion is disabled"
Applicable to: Building, Unit, Ruins, Tutorial
??? example "Hidden when espionage is disabled"
Applicable to: Building
??? example "Hidden when [victoryType] Victory is disabled"
Example: "Hidden when [Domination] Victory is disabled"

View File

@ -0,0 +1,57 @@
package com.unciv.logic.civilization
import com.badlogic.gdx.math.Vector2
import com.unciv.testing.GdxTestRunner
import com.unciv.testing.TestGame
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(GdxTestRunner::class)
class EspionageTests {
private val testGame = TestGame()
val civA = testGame.addCiv()
val civB = testGame.addCiv()
@Before
fun setup() {
testGame.gameInfo.gameParameters.espionageEnabled = true
civA.diplomacyFunctions.makeCivilizationsMeet(civB)
testGame.makeHexagonalMap(3)
}
@Test
fun `Espionage manager add spy`() {
val espionageManagerA = civA.espionageManager
assertEquals(0, espionageManagerA.spyList.size)
espionageManagerA.addSpy()
assertEquals(1, espionageManagerA.spyList.size)
}
@Test
fun `Espionage check spy effectiveness reduction unique`() {
val espionageManagerA = civA.espionageManager
val spy = espionageManagerA.addSpy()
val city = civB.addCity(Vector2(1f,1f))
spy.moveTo(city)
assertEquals(1.0, spy.getEfficiencyModifier(), 0.1)
city.cityConstructions.addBuilding("Constabulary")
assertEquals(0.75, spy.getEfficiencyModifier(), 0.1)
}
@Test
fun `Spy effectiveness can't go below zero`() {
val espionageManagerA = civA.espionageManager
val spy = espionageManagerA.addSpy()
val city = civB.addCity(Vector2(1f,1f))
spy.moveTo(city)
city.cityConstructions.addBuilding("Constabulary")
city.cityConstructions.addBuilding("Police Station")
city.cityConstructions.addBuilding("National Intelligence Agency")
city.cityConstructions.addBuilding("Great Firewall")
assertTrue(spy.getEfficiencyModifier() >= 0)
}
}