Extra Civ and Spy moddability (#11702)

* Made minimum duration of a war moddable

* Made turns until revolt moddable

* Made spy skill moddable

* Migrated city-state elections to use the Civ flag system

* Moved cityStateElectionTurns away from espionage

* Added new moddable constants to the documentation

* Fixed merge conflicts
This commit is contained in:
Oskar Niesen 2024-06-10 14:22:09 -05:00 committed by GitHub
parent 7012297af6
commit b496784ab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 97 additions and 70 deletions

View File

@ -304,7 +304,6 @@ class Civilization : IsPartOfGameInfoSerialization {
toReturn.hasMovedAutomatedUnits = hasMovedAutomatedUnits
toReturn.statsHistory = statsHistory.clone()
toReturn.resourceStockpiles = resourceStockpiles.clone()
toReturn.cityStateTurnsUntilElection = cityStateTurnsUntilElection
return toReturn
}
@ -365,7 +364,6 @@ class Civilization : IsPartOfGameInfoSerialization {
var cityStatePersonality: CityStatePersonality = CityStatePersonality.Neutral
var cityStateResource: String? = null
var cityStateUniqueUnit: String? = null // Unique unit for militaristic city state. Might still be null if there are no appropriate units
var cityStateTurnsUntilElection: Int = 0
fun hasMetCivTerritory(otherCiv: Civilization): Boolean =
otherCiv.getCivTerritory().any { gameInfo.tileMap[it].isExplored(this) }
@ -1014,6 +1012,7 @@ class CivilizationInfoPreview() {
enum class CivFlags {
CityStateGreatPersonGift,
TurnsTillCityStateElection,
TurnsTillNextDiplomaticVote,
ShowDiplomaticVotingResults,
ShouldResetDiplomaticVotes,

View File

@ -64,58 +64,57 @@ class CityStateFunctions(val civInfo: Civilization) {
}
// Set turns to elections to a random number so not every city-state has the same election date
civInfo.cityStateTurnsUntilElection = Random.nextInt(15)
if (civInfo.gameInfo.isEspionageEnabled()) {
civInfo.addFlag(CivFlags.TurnsTillCityStateElection.name, Random.nextInt(civInfo.gameInfo.ruleset.modOptions.constants.cityStateElectionTurns + 1))
}
// TODO: Return false if attempting to put a religious city-state in a game without religion
return true
}
fun nextTurnElections() {
civInfo.cityStateTurnsUntilElection--
val capital = civInfo.getCapital()
if (civInfo.cityStateTurnsUntilElection <= 0) {
if (capital == null) return
civInfo.cityStateTurnsUntilElection = 15
val spies= capital.espionage.getAllStationedSpies().filter { it.action == SpyAction.RiggingElections }
if (spies.isEmpty()) return
fun holdElections() {
civInfo.addFlag(CivFlags.TurnsTillCityStateElection.name, civInfo.gameInfo.ruleset.modOptions.constants.cityStateElectionTurns)
val capital = civInfo.getCapital() ?: return
fun getVotesFromSpy(spy: Spy?): Float {
if (spy == null) return 20f
var votes = (civInfo.getDiplomacyManager(spy.civInfo).influence / 2)
votes += (spy.getSkillModifier() * spy.getEfficiencyModifier()).toFloat() // ranges from 30 to 90
return votes
val spies = capital.espionage.getAllStationedSpies().filter { it.action == SpyAction.RiggingElections }
if (spies.isEmpty()) return
fun getVotesFromSpy(spy: Spy?): Float {
if (spy == null) return 20f
var votes = (civInfo.getDiplomacyManager(spy.civInfo).influence / 2)
votes += (spy.getSkillModifierPercent() * spy.getEfficiencyModifier()).toFloat() // ranges from 30 to 90
return votes
}
val parties: MutableList<Spy?> = spies.toMutableList()
parties.add(null) // Null spy is a neuteral party in the election
val randomSeed = capital.location.x * capital.location.y + 123f * civInfo.gameInfo.turns
val winner: Civilization? = parties.randomWeighted(Random(randomSeed.toInt())) { getVotesFromSpy(it) }?.civInfo
// There may be no winner, in that case all spies will loose 5 influence
if (winner != null) {
val allyCiv = civInfo.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) }
// Winning civ gets influence and all others loose influence
for (civ in civInfo.getKnownCivs().toList()) {
val influence = if (civ == winner) 20f else -5f
civInfo.getDiplomacyManager(civ).addInfluence(influence)
if (civ == winner) {
civ.addNotification("Your spy successfully rigged the election in [${civInfo.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
} else if (spies.any { it.civInfo == civ}) {
civ.addNotification("Your spy lost the election in [${civInfo.civName}] to [${winner.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
} else if (civ == allyCiv) {
// If the previous ally has no spy in the city then we should notify them
allyCiv.addNotification("The election in [${civInfo.civName}] were rigged by [${winner.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
}
}
val parties: MutableList<Spy?> = spies.toMutableList()
parties.add(null) // Null spy is a neuteral party in the election
val randomSeed = capital.location.x * capital.location.y + 123f * civInfo.gameInfo.turns
val winner: Civilization? = parties.randomWeighted(Random(randomSeed.toInt())) { getVotesFromSpy(it) }?.civInfo
// There may be no winner, in that case all spies will loose 5 influence
if (winner != null) {
val allyCiv = civInfo.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) }
// Winning civ gets influence and all others loose influence
for (civ in civInfo.getKnownCivs().toList()) {
val influence = if (civ == winner) 20f else -5f
civInfo.getDiplomacyManager(civ).addInfluence(influence)
if (civ == winner) {
civ.addNotification("Your spy successfully rigged the election in [${civInfo.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
} else if (spies.any { it.civInfo == civ}) {
civ.addNotification("Your spy lost the election in [${civInfo.civName}] to [${winner.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
} else if (civ == allyCiv) {
// If the previous ally has no spy in the city then we should notify them
allyCiv.addNotification("The election in [${civInfo.civName}] were rigged by [${winner.civName}]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
}
}
} else {
// No spy won the election, the civs that tried to rig the election loose influence
for (spy in spies) {
civInfo.getDiplomacyManager(spy.civInfo).addInfluence(-5f)
spy.civInfo.addNotification("Your spy lost the election in [$capital]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
}
} else {
// No spy won the election, the civs that tried to rig the election loose influence
for (spy in spies) {
civInfo.getDiplomacyManager(spy.civInfo).addInfluence(-5f)
spy.civInfo.addNotification("Your spy lost the election in [$capital]!", capital.location, NotificationCategory.Espionage, NotificationIcon.Spy)
}
}
}

View File

@ -157,8 +157,8 @@ object DeclareWar {
diplomacyManager.updateHasOpenBorders()
diplomacyManager.removeModifier(DiplomaticModifiers.YearsOfPeace)
diplomacyManager.setFlag(DiplomacyFlags.DeclinedPeace, 3)/// AI won't propose peace for 3 turns
diplomacyManager.setFlag(DiplomacyFlags.DeclaredWar, 10) // AI won't agree to trade for 10 turns
diplomacyManager.setFlag(DiplomacyFlags.DeclinedPeace, diplomacyManager.civInfo.gameInfo.ruleset.modOptions.constants.minimumWarDuration) // AI won't propose peace for 10 turns
diplomacyManager.setFlag(DiplomacyFlags.DeclaredWar, diplomacyManager.civInfo.gameInfo.ruleset.modOptions.constants.minimumWarDuration) // AI won't agree to trade for 10 turns
diplomacyManager.removeFlag(DiplomacyFlags.BorderConflict)
}

View File

@ -23,7 +23,6 @@ import com.unciv.models.stats.Stats
import com.unciv.ui.components.MayaCalendar
import com.unciv.ui.screens.worldscreen.status.NextTurnProgress
import com.unciv.utils.Log
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
@ -126,6 +125,7 @@ class TurnManager(val civInfo: Civilization) {
when (flag) {
CivFlags.RevoltSpawning.name -> doRevoltSpawn()
CivFlags.TurnsTillCityStateElection.name -> civInfo.cityStateFunctions.holdElections()
}
}
handleDiplomaticVictoryFlags()
@ -166,7 +166,7 @@ class TurnManager(val civInfo: Civilization) {
}
if (!civInfo.hasFlag(CivFlags.RevoltSpawning.name)) {
civInfo.addFlag(CivFlags.RevoltSpawning.name, max(getTurnsBeforeRevolt(),1))
civInfo.addFlag(CivFlags.RevoltSpawning.name, getTurnsBeforeRevolt().coerceAtLeast(1))
return
}
}
@ -223,7 +223,8 @@ class TurnManager(val civInfo: Civilization) {
}
private fun getTurnsBeforeRevolt() =
((4 + Random.Default.nextInt(3)) * max(civInfo.gameInfo.speed.modifier, 1f)).toInt()
((civInfo.gameInfo.ruleset.modOptions.constants.baseTurnsUntilRevolt + Random.Default.nextInt(3))
* civInfo.gameInfo.speed.modifier.coerceAtLeast(1f)).toInt()
fun endTurn(progressBar: NextTurnProgress? = null) {
@ -261,7 +262,11 @@ class TurnManager(val civInfo: Civilization) {
if (civInfo.isCityState()) {
civInfo.questManager.endTurn()
civInfo.cityStateFunctions.nextTurnElections()
// Todo: Remove this later
// The purpouse of this addition is to migrate the old election system to the new flag system
if (civInfo.gameInfo.isEspionageEnabled() && !civInfo.hasFlag(CivFlags.TurnsTillCityStateElection.name)) {
civInfo.addFlag(CivFlags.TurnsTillCityStateElection.name, Random.nextInt(civInfo.gameInfo.ruleset.modOptions.constants.cityStateElectionTurns + 1))
}
}
// disband units until there are none left OR the gold values are normal

View File

@ -87,7 +87,18 @@ class ModConstants {
var workboatAutomationSearchMaxTiles = 20
var maxSpyLevel = 3
// Civilization
var minimumWarDuration = 10
var baseTurnsUntilRevolt = 4
var cityStateElectionTurns = 15
// Espionage
var maxSpyRank = 3
// How much of a skill bonus each rank gives.
// Rank 0 is 100%, rank 1 is 130%, and so on for stealing technology.
// Half as much for a coup.
var spyRankSkillPercentBonus = 30
fun merge(other: ModConstants) {
for (field in this::class.java.declaredFields) {

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.city.City
import com.unciv.logic.civilization.CivFlags
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.EspionageAction
import com.unciv.logic.civilization.NotificationCategory
@ -102,7 +103,7 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
SpyAction.EstablishNetwork -> {
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, getCity().civ.cityStateTurnsUntilElection - 1)
setAction(SpyAction.RiggingElections, (getCity().civ.flagsCountdown[CivFlags.TurnsTillCityStateElection.name] ?: 1) - 1)
else if (city.civ == civInfo)
setAction(SpyAction.CounterIntelligence, 10)
else
@ -130,7 +131,9 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
SpyAction.RiggingElections -> {
// No action done here
// Handled in CityStateFunctions.nextTurnElections()
turnsRemainingForAction = getCity().civ.cityStateTurnsUntilElection - 1
// TODO: Once we remove support for the old flag system we can remove the null check
// Our spies might update before the flag is created in the city-state
turnsRemainingForAction = (getCity().civ.flagsCountdown[CivFlags.TurnsTillCityStateElection.name] ?: 0) - 1
}
SpyAction.Coup -> {
initiateCoup()
@ -184,6 +187,14 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
}
}
/**
* With the defult spy leveling:
* 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.
*/
private fun stealTech() {
val city = getCity()
val otherCiv = city.civ
@ -195,10 +206,10 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
// Lower is better
var spyResult = Random(randomSeed).nextInt(300)
// Add our spies experience
spyResult -= getSkillModifier()
spyResult -= getSkillModifierPercent()
// Subtract the experience of the counter intelligence spies
val defendingSpy = city.civ.espionageManager.getSpyAssignedToCity(city)
spyResult += defendingSpy?.getSkillModifier() ?: 0
spyResult += defendingSpy?.getSkillModifierPercent() ?: 0
val detectionString = when {
spyResult >= 200 -> { // The spy was killed in the attempt (should be able to happen even if there's nothing to steal?)
@ -309,7 +320,7 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
cityState.getAllyCiv()?.let { civInfo.gameInfo.getCivilization(it) }?.espionageManager?.getSpyAssignedToCity(getCity())
else null
val spyRanks = getSkillModifier() - (defendingSpy?.getSkillModifier() ?: 0)
val spyRanks = getSkillModifierPercent() - (defendingSpy?.getSkillModifierPercent() ?: 0)
successPercentage += spyRanks / 2f // Each rank counts for 15%
successPercentage = successPercentage.coerceIn(0f, 85f)
@ -354,24 +365,16 @@ class Spy private constructor() : IsPartOfGameInfoSerialization {
fun getLocationName() = getCityOrNull()?.name ?: Constants.spyHideout
fun levelUpSpy(amount: Int = 1) {
if (rank >= civInfo.gameInfo.ruleset.modOptions.constants.maxSpyLevel) return
val ranksToLevelUp = amount.coerceAtMost(civInfo.gameInfo.ruleset.modOptions.constants.maxSpyLevel - rank)
if (rank >= civInfo.gameInfo.ruleset.modOptions.constants.maxSpyRank) return
val ranksToLevelUp = amount.coerceAtMost(civInfo.gameInfo.ruleset.modOptions.constants.maxSpyRank - rank)
if (ranksToLevelUp == 1) addNotification("Your spy [$name] has leveled up!")
else addNotification("Your spy [$name] has leveled up [$ranksToLevelUp] times!")
rank += ranksToLevelUp
}
/** 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?
fun getSkillModifier() = rank * 30
/** Modifier of the skill bonus of the spy by percent */
fun getSkillModifierPercent() = rank * civInfo.gameInfo.ruleset.modOptions.constants.spyRankSkillPercentBonus
/**
* Gets a friendly and enemy efficiency uniques for the spy at the location

View File

@ -209,6 +209,11 @@ and city distance in another. In case of conflicts, there is no guarantee which
| pantheonBase | Int | 10 | [^L] |
| pantheonGrowth | Int | 5 | [^L] |
| workboatAutomationSearchMaxTiles | Int | 20 | [^M] |
| maxSpyRank | Int | 3 | [^N] |
| spyRankSkillPercentBonus | Float | 30 | [^O] |
| minimumWarDuration | Int | 10 | [^P] |
| baseTurnsUntilRevolt | Int | 4 | [^Q] |
| cityStateElectionTurns | Int | 15 | [^R] |
Legend:
@ -238,7 +243,12 @@ Legend:
- [^J]: A [UnitUpgradeCost](#unitupgradecost) sub-structure.
- [^K]: Maximum foundable Religions = religionLimitBase + floor(MajorCivCount * religionLimitMultiplier)
- [^L]: Cost of pantheon = pantheonBase + CivsWithReligion * pantheonGrowth
- [^M]: When the AI decidees whether to build a work boat, how many tiles to search from the city center for an improvable tile
- [^M]: When the AI decides whether to build a work boat, how many tiles to search from the city center for an improvable tile
- [^N]: The maximum rank any spy can reach
- [^O]: How much skill bonus each rank gives
- [^P]: The number of turns a civ has to wait before negotiating for peace
- [^Q]: The number of turns before a revolt is spawned
- [^R]: The number of turns between city-state elections
#### UnitUpgradeCost