diff --git a/core/src/com/unciv/logic/civilization/Civilization.kt b/core/src/com/unciv/logic/civilization/Civilization.kt index 99620268bf..96d883c6a5 100644 --- a/core/src/com/unciv/logic/civilization/Civilization.kt +++ b/core/src/com/unciv/logic/civilization/Civilization.kt @@ -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, diff --git a/core/src/com/unciv/logic/civilization/diplomacy/CityStateFunctions.kt b/core/src/com/unciv/logic/civilization/diplomacy/CityStateFunctions.kt index a263f450b9..8e8bb0dfe8 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/CityStateFunctions.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/CityStateFunctions.kt @@ -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 = 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 = 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) } } } diff --git a/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt b/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt index f0977543d9..627a396911 100644 --- a/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt +++ b/core/src/com/unciv/logic/civilization/diplomacy/DeclareWar.kt @@ -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) } diff --git a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt index a7f740292d..02da82a170 100644 --- a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt @@ -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 diff --git a/core/src/com/unciv/models/ModConstants.kt b/core/src/com/unciv/models/ModConstants.kt index 2a73cbcc5b..d92a1f58fe 100644 --- a/core/src/com/unciv/models/ModConstants.kt +++ b/core/src/com/unciv/models/ModConstants.kt @@ -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) { diff --git a/core/src/com/unciv/models/Spy.kt b/core/src/com/unciv/models/Spy.kt index 77538f9cd7..e3dcc71932 100644 --- a/core/src/com/unciv/models/Spy.kt +++ b/core/src/com/unciv/models/Spy.kt @@ -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 diff --git a/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md b/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md index 869d1ef1b3..c7133caace 100644 --- a/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md +++ b/docs/Modders/Mod-file-structure/5-Miscellaneous-JSON-files.md @@ -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