diff --git a/core/src/com/unciv/logic/city/CityFocus.kt b/core/src/com/unciv/logic/city/CityFocus.kt new file mode 100644 index 0000000000..8a8eb1e9dd --- /dev/null +++ b/core/src/com/unciv/logic/city/CityFocus.kt @@ -0,0 +1,48 @@ +package com.unciv.logic.city + +import com.unciv.logic.IsPartOfGameInfoSerialization +import com.unciv.models.stats.Stat +import com.unciv.models.stats.Stats + +// if tableEnabled == true, then Stat != null +enum class CityFocus(val label: String, val tableEnabled: Boolean, val stat: Stat? = null) : + IsPartOfGameInfoSerialization { + NoFocus("Default Focus", true, null) { + override fun getStatMultiplier(stat: Stat) = 1f // actually redundant, but that's two steps to see + }, + FoodFocus("[${Stat.Food.name}] Focus", true, Stat.Food), + ProductionFocus("[${Stat.Production.name}] Focus", true, Stat.Production), + GoldFocus("[${Stat.Gold.name}] Focus", true, Stat.Gold), + ScienceFocus("[${Stat.Science.name}] Focus", true, Stat.Science), + CultureFocus("[${Stat.Culture.name}] Focus", true, Stat.Culture), + GoldGrowthFocus("Gold Growth Focus", false) { + override fun getStatMultiplier(stat: Stat) = when (stat) { + Stat.Gold, Stat.Food -> 2f + else -> 1f + } + }, + ProductionGrowthFocus("Production Growth Focus", false) { + override fun getStatMultiplier(stat: Stat) = when (stat) { + Stat.Production, Stat.Food -> 2f + else -> 1f + } + }, + FaithFocus("[${Stat.Faith.name}] Focus", true, Stat.Faith), + HappinessFocus("[${Stat.Happiness.name}] Focus", false, Stat.Happiness); + //GreatPersonFocus; + + open fun getStatMultiplier(stat: Stat) = when (this.stat) { + stat -> 3f + else -> 1f + } + + fun applyWeightTo(stats: Stats) { + for (stat in Stat.values()) { + stats[stat] *= getStatMultiplier(stat) + } + } + + fun safeValueOf(stat: Stat): CityFocus { + return values().firstOrNull { it.stat == stat } ?: NoFocus + } +} diff --git a/core/src/com/unciv/logic/city/CityInfo.kt b/core/src/com/unciv/logic/city/CityInfo.kt index 3ca1a7f441..7587156380 100644 --- a/core/src/com/unciv/logic/city/CityInfo.kt +++ b/core/src/com/unciv/logic/city/CityInfo.kt @@ -9,17 +9,12 @@ import com.unciv.logic.city.managers.CityInfoConquestFunctions import com.unciv.logic.city.managers.CityPopulationManager import com.unciv.logic.city.managers.CityReligionManager import com.unciv.logic.civilization.CivilizationInfo -import com.unciv.logic.civilization.NotificationCategory -import com.unciv.logic.civilization.NotificationIcon -import com.unciv.logic.civilization.Proximity import com.unciv.logic.civilization.diplomacy.DiplomacyFlags -import com.unciv.logic.civilization.managers.ReligionState import com.unciv.logic.map.RoadStatus import com.unciv.logic.map.TileInfo import com.unciv.logic.map.TileMap import com.unciv.models.Counter import com.unciv.models.ruleset.ModOptionsConstants -import com.unciv.models.ruleset.nation.Nation import com.unciv.models.ruleset.tile.ResourceSupplyList import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.unique.StateForConditionals @@ -27,10 +22,8 @@ import com.unciv.models.ruleset.unique.Unique import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.stats.Stat -import com.unciv.models.stats.Stats import java.util.* import kotlin.math.ceil -import kotlin.math.min import kotlin.math.pow import kotlin.math.roundToInt @@ -40,48 +33,6 @@ enum class CityFlags { Resistance } -// if tableEnabled == true, then Stat != null -enum class CityFocus(val label: String, val tableEnabled: Boolean, val stat: Stat? = null) : IsPartOfGameInfoSerialization { - NoFocus("Default Focus", true, null) { - override fun getStatMultiplier(stat: Stat) = 1f // actually redundant, but that's two steps to see - }, - FoodFocus("[${Stat.Food.name}] Focus", true, Stat.Food), - ProductionFocus("[${Stat.Production.name}] Focus", true, Stat.Production), - GoldFocus("[${Stat.Gold.name}] Focus", true, Stat.Gold), - ScienceFocus("[${Stat.Science.name}] Focus", true, Stat.Science), - CultureFocus("[${Stat.Culture.name}] Focus", true, Stat.Culture), - GoldGrowthFocus("Gold Growth Focus", false) { - override fun getStatMultiplier(stat: Stat) = when (stat) { - Stat.Gold, Stat.Food -> 2f - else -> 1f - } - }, - ProductionGrowthFocus("Production Growth Focus", false) { - override fun getStatMultiplier(stat: Stat) = when (stat) { - Stat.Production, Stat.Food -> 2f - else -> 1f - } - }, - FaithFocus("[${Stat.Faith.name}] Focus", true, Stat.Faith), - HappinessFocus("[${Stat.Happiness.name}] Focus", false, Stat.Happiness); - //GreatPersonFocus; - - open fun getStatMultiplier(stat: Stat) = when (this.stat) { - stat -> 3f - else -> 1f - } - - fun applyWeightTo(stats: Stats) { - for (stat in Stat.values()) { - stats[stat] *= getStatMultiplier(stat) - } - } - - fun safeValueOf(stat: Stat): CityFocus { - return values().firstOrNull { it.stat == stat } ?: NoFocus - } -} - class CityInfo : IsPartOfGameInfoSerialization { @Suppress("JoinDeclarationAndAssignment") @@ -149,199 +100,10 @@ class CityInfo : IsPartOfGameInfoSerialization { /** For We Love the King Day */ var demandedResource = "" - private var flagsCountdown = HashMap() + internal var flagsCountdown = HashMap() fun hasDiplomaticMarriage(): Boolean = foundingCiv == "" - constructor() // for json parsing, we need to have a default constructor - constructor(civInfo: CivilizationInfo, cityLocation: Vector2) { // new city! - this.civInfo = civInfo - foundingCiv = civInfo.civName - turnAcquired = civInfo.gameInfo.turns - location = cityLocation - setTransients() - - name = generateNewCityName( - civInfo, - civInfo.gameInfo.civilizations.asSequence().filter { civ -> civ.isAlive() }.toSet(), - arrayListOf("New ", "Neo ", "Nova ", "Altera ") - ) ?: "City Without A Name" - - isOriginalCapital = civInfo.citiesCreated == 0 - if (isOriginalCapital) { - civInfo.hasEverOwnedOriginalCapital = true - // if you have some culture before the 1st city is found, you may want to adopt the 1st policy - civInfo.policies.shouldOpenPolicyPicker = true - } - civInfo.citiesCreated++ - - civInfo.cities = civInfo.cities.toMutableList().apply { add(this@CityInfo) } - - val startingEra = civInfo.gameInfo.gameParameters.startingEra - - addStartingBuildings(civInfo, startingEra) - - expansion.reset() - - tryUpdateRoadStatus() - - val tile = getCenterTile() - for (terrainFeature in tile.terrainFeatures.filter { - getRuleset().tileImprovements.containsKey( - "Remove $it" - ) - }) - tile.removeTerrainFeature(terrainFeature) - - tile.changeImprovement(null) - tile.improvementInProgress = null - - val ruleset = civInfo.gameInfo.ruleSet - workedTiles = hashSetOf() //reassign 1st working tile - - population.setPopulation(ruleset.eras[startingEra]!!.settlerPopulation) - - if (civInfo.religionManager.religionState == ReligionState.Pantheon) { - religion.addPressure( - civInfo.religionManager.religion!!.name, - 200 * population.population - ) - } - - population.autoAssignPopulation() - - // Update proximity rankings for all civs - for (otherCiv in civInfo.gameInfo.getAliveMajorCivs()) { - if (civInfo.getProximity(otherCiv) != Proximity.Neighbors) // unless already neighbors - civInfo.cache.updateProximity(otherCiv, - otherCiv.cache.updateProximity(civInfo)) - } - for (otherCiv in civInfo.gameInfo.getAliveCityStates()) { - if (civInfo.getProximity(otherCiv) != Proximity.Neighbors) // unless already neighbors - civInfo.cache.updateProximity(otherCiv, - otherCiv.cache.updateProximity(civInfo)) - } - - triggerCitiesSettledNearOtherCiv() - - civInfo.gameInfo.cityDistances.setDirty() - } - - private fun addStartingBuildings(civInfo: CivilizationInfo, startingEra: String) { - val ruleset = civInfo.gameInfo.ruleSet - if (civInfo.cities.size == 1) cityConstructions.addBuilding(capitalCityIndicator()) - - // Add buildings and pop we get from starting in this era - for (buildingName in ruleset.eras[startingEra]!!.settlerBuildings) { - val building = ruleset.buildings[buildingName] ?: continue - val uniqueBuilding = civInfo.getEquivalentBuilding(building) - if (uniqueBuilding.isBuildable(cityConstructions)) - cityConstructions.addBuilding(uniqueBuilding.name) - } - - civInfo.civConstructions.tryAddFreeBuildings() - cityConstructions.addFreeBuildings() - } - - /** - * Generates and returns a new city name for the [foundingCiv]. - * - * This method attempts to return the first unused city name of the [foundingCiv], taking used - * city names into consideration (including foreign cities). If that fails, it then checks - * whether the civilization has [UniqueType.BorrowsCityNames] and, if true, returns a borrowed - * name. Else, it repeatedly attaches one of the given [prefixes] to the list of names up to ten - * times until an unused name is successfully generated. If all else fails, null is returned. - * - * @param foundingCiv The civilization that founded this city. - * @param aliveCivs Every civilization currently alive. - * @param prefixes Prefixes to add when every base name is taken, ordered. - * @return A new city name in [String]. Null if failed to generate a name. - */ - private fun generateNewCityName( - foundingCiv: CivilizationInfo, - aliveCivs: Set, - prefixes: List - ): String? { - val usedCityNames: Set = - aliveCivs.asSequence().flatMap { civilization -> - civilization.cities.asSequence().map { city -> city.name } - }.toSet() - - // Attempt to return the first missing name from the list of city names - for (cityName in foundingCiv.nation.cities) { - if (cityName !in usedCityNames) return cityName - } - - // If all names are taken and this nation borrows city names, - // return a random borrowed city name - if (foundingCiv.hasUnique(UniqueType.BorrowsCityNames)) { - return borrowCityName(foundingCiv, aliveCivs, usedCityNames) - } - - // If the nation doesn't have the unique above, - // return the first missing name with an increasing number of prefixes attached - // TODO: Make prefixes moddable per nation? Support suffixes? - var candidate: String? - for (number in (1..10)) { - for (prefix in prefixes) { - val currentPrefix: String = prefix.repeat(number) - candidate = foundingCiv.nation.cities.firstOrNull { cityName -> - (currentPrefix + cityName) !in usedCityNames - } - if (candidate != null) return currentPrefix + candidate - } - } - - // If all else fails (by using some sort of rule set mod without city names), - return null - } - - /** - * Borrows a city name from another major civilization. - * - * @param foundingCiv The civilization that founded this city. - * @param aliveCivs Every civilization currently alive. - * @param usedCityNames Every city name that have already been taken. - * @return A new city named in [String]. Null if failed to generate a name. - */ - private fun borrowCityName( - foundingCiv: CivilizationInfo, - aliveCivs: Set, - usedCityNames: Set - ): String? { - val aliveMajorNations: Sequence = - aliveCivs.asSequence().filter { civ -> civ.isMajorCiv() }.map { civ -> civ.nation } - - /* - We take the last unused city name for each other major nation in this game, - skipping nations whose names are exhausted, - and choose a random one from that pool if it's not empty. - */ - val otherMajorNations: Sequence = - aliveMajorNations.filter { nation -> nation != foundingCiv.nation } - var newCityNames: Set = - otherMajorNations.mapNotNull { nation -> - nation.cities.lastOrNull { city -> city !in usedCityNames } - }.toSet() - if (newCityNames.isNotEmpty()) return newCityNames.random() - - // As per fandom wiki, once the names from the other nations in the game are exhausted, - // names are taken from the rest of the major nations in the rule set - val absentMajorNations: Sequence = - getRuleset().nations.values.asSequence().filter { nation -> - nation.isMajorCiv() && nation !in aliveMajorNations - } - newCityNames = - absentMajorNations.flatMap { nation -> - nation.cities.asSequence().filter { city -> city !in usedCityNames } - }.toSet() - if (newCityNames.isNotEmpty()) return newCityNames.random() - - // If for some reason we have used every single city name in the game, - // (are we using some sort of rule set mod without city names?) - return null - } - //region pure functions fun clone(): CityInfo { val toReturn = CityInfo() @@ -634,7 +396,8 @@ class CityInfo : IsPartOfGameInfoSerialization { //endregion //region state-changing functions - fun setTransients() { + fun setTransients(civInfo: CivilizationInfo) { + this.civInfo = civInfo tileMap = civInfo.gameInfo.tileMap centerTileInfo = tileMap[location] tilesInRange = getCenterTile().getTilesInDistance(3).toHashSet() @@ -647,65 +410,6 @@ class CityInfo : IsPartOfGameInfoSerialization { espionage.setTransients(this) } - fun startTurn() { - // Construct units at the beginning of the turn, - // so they won't be generated out in the open and vulnerable to enemy attacks before you can control them - cityConstructions.constructIfEnough() - cityConstructions.addFreeBuildings() - - cityStats.update() - tryUpdateRoadStatus() - attackedThisTurn = false - - if (isPuppet) { - cityAIFocus = CityFocus.GoldFocus - reassignAllPopulation() - } else if (updateCitizens) { - reassignPopulation() - updateCitizens = false - } - - // The ordering is intentional - you get a turn without WLTKD even if you have the next resource already - if (!hasFlag(CityFlags.WeLoveTheKing)) - tryWeLoveTheKing() - nextTurnFlags() - - // Seed resource demand countdown - if(demandedResource == "" && !hasFlag(CityFlags.ResourceDemand)) { - setFlag(CityFlags.ResourceDemand, - (if (isCapital()) 25 else 15) + Random().nextInt(10)) - } - } - - // cf DiplomacyManager nextTurnFlags - private fun nextTurnFlags() { - for (flag in flagsCountdown.keys.toList()) { - if (flagsCountdown[flag]!! > 0) - flagsCountdown[flag] = flagsCountdown[flag]!! - 1 - - if (flagsCountdown[flag] == 0) { - flagsCountdown.remove(flag) - - when (flag) { - CityFlags.ResourceDemand.name -> { - demandNewResource() - } - CityFlags.WeLoveTheKing.name -> { - civInfo.addNotification( - "We Love The King Day in [$name] has ended.", - location, NotificationCategory.General, NotificationIcon.City) - demandNewResource() - } - CityFlags.Resistance.name -> { - civInfo.addNotification( - "The resistance in [$name] has ended!", - location, NotificationCategory.General, "StatIcons/Resistance") - } - } - } - } - } - fun setFlag(flag: CityFlags, amount: Int) { flagsCountdown[flag.name] = amount } @@ -741,39 +445,6 @@ class CityInfo : IsPartOfGameInfoSerialization { population.autoAssignPopulation() } - fun endTurn() { - val stats = cityStats.currentCityStats - - cityConstructions.endTurn(stats) - expansion.nextTurn(stats.culture) - if (isBeingRazed) { - val removedPopulation = - 1 + civInfo.getMatchingUniques(UniqueType.CitiesAreRazedXTimesFaster) - .sumOf { it.params[0].toInt() - 1 } - population.addPopulation(-1 * removedPopulation) - if (population.population <= 0) { - civInfo.addNotification( - "[$name] has been razed to the ground!", - location, NotificationCategory.General, - "OtherIcons/Fire" - ) - destroyCity() - } else { //if not razed yet: - if (population.foodStored >= population.getFoodToNextPopulation()) { //if surplus in the granary... - population.foodStored = - population.getFoodToNextPopulation() - 1 //...reduce below the new growth threshold - } - } - } else population.nextTurn(foodForNextTurn()) - - // This should go after the population change, as that might impact the amount of followers in this city - if (civInfo.gameInfo.isReligionEnabled()) religion.endTurn() - - if (this in civInfo.cities) { // city was not destroyed - health = min(health + 20, getMaxHealth()) - population.unassignExtraPopulation() - } - } fun destroyCity(overrideSafeties: Boolean = false) { // Original capitals and holy cities cannot be destroyed, @@ -857,62 +528,6 @@ class CityInfo : IsPartOfGameInfoSerialization { civInfo.cache.updateCivResources() // this building could be a resource-requiring one } - private fun demandNewResource() { - val candidates = getRuleset().tileResources.values.filter { - it.resourceType == ResourceType.Luxury && // Must be luxury - !it.hasUnique(UniqueType.CityStateOnlyResource) && // Not a city-state only resource eg jewelry - it.name != demandedResource && // Not same as last time - !civInfo.hasResource(it.name) && // Not one we already have - it.name in tileMap.resources && // Must exist somewhere on the map - getCenterTile().getTilesInDistance(3).none { nearTile -> nearTile.resource == it.name } // Not in this city's radius - } - - val chosenResource = candidates.randomOrNull() - /* What if we had a WLTKD before but now the player has every resource in the game? We can't - pick a new resource, so the resource will stay stay the same and the city will demand it - again even if the player still has it. But we shouldn't punish success. */ - if (chosenResource != null) - demandedResource = chosenResource.name - if (demandedResource == "") // Failed to get a valid resource, try again some time later - setFlag(CityFlags.ResourceDemand, 15 + Random().nextInt(10)) - else - civInfo.addNotification("[$name] demands [$demandedResource]!", location, NotificationCategory.General, NotificationIcon.City, "ResourceIcons/$demandedResource") - } - - private fun tryWeLoveTheKing() { - if (demandedResource == "") return - if (civInfo.getCivResourcesByName()[demandedResource]!! > 0) { - setFlag(CityFlags.WeLoveTheKing, 20 + 1) // +1 because it will be decremented by 1 in the same startTurn() - civInfo.addNotification( - "Because they have [$demandedResource], the citizens of [$name] are celebrating We Love The King Day!", - location, NotificationCategory.General, NotificationIcon.City, NotificationIcon.Happiness) - } - } - - /* - When someone settles a city within 6 tiles of another civ, this makes the AI unhappy and it starts a rolling event. - The SettledCitiesNearUs flag gets added to the AI so it knows this happened, - and on its turn it asks the player to stop (with a DemandToStopSettlingCitiesNear alert type) - If the player says "whatever, I'm not promising to stop", they get a -10 modifier which gradually disappears in 40 turns - If they DO agree, then if they keep their promise for ~100 turns they get a +10 modifier for keeping the promise, - But if they don't keep their promise they get a -20 that will only fully disappear in 160 turns. - There's a lot of triggering going on here. - */ - private fun triggerCitiesSettledNearOtherCiv() { - val citiesWithin6Tiles = - civInfo.gameInfo.civilizations.asSequence() - .filter { it.isMajorCiv() && it != civInfo } - .flatMap { it.cities } - .filter { it.getCenterTile().aerialDistanceTo(getCenterTile()) <= 6 } - val civsWithCloseCities = - citiesWithin6Tiles - .map { it.civInfo } - .distinct() - .filter { it.knows(civInfo) && it.hasExplored(location) } - for (otherCiv in civsWithCloseCities) - otherCiv.getDiplomacyManager(civInfo).setFlag(DiplomacyFlags.SettledCitiesNearUs, 30) - } - fun canPlaceNewUnit(construction: BaseUnit): Boolean { val tile = getCenterTile() return when { diff --git a/core/src/com/unciv/logic/city/managers/CityFounder.kt b/core/src/com/unciv/logic/city/managers/CityFounder.kt new file mode 100644 index 0000000000..8ca6b47079 --- /dev/null +++ b/core/src/com/unciv/logic/city/managers/CityFounder.kt @@ -0,0 +1,229 @@ +package com.unciv.logic.city.managers + +import com.badlogic.gdx.math.Vector2 +import com.unciv.logic.city.CityInfo +import com.unciv.logic.civilization.CivilizationInfo +import com.unciv.logic.civilization.Proximity +import com.unciv.logic.civilization.diplomacy.DiplomacyFlags +import com.unciv.logic.civilization.managers.ReligionState +import com.unciv.models.ruleset.nation.Nation +import com.unciv.models.ruleset.unique.UniqueType + +class CityFounder { + fun foundCity(civInfo: CivilizationInfo, cityLocation: Vector2) :CityInfo{ + val cityInfo = CityInfo() + + cityInfo.foundingCiv = civInfo.civName + cityInfo.turnAcquired = civInfo.gameInfo.turns + cityInfo.location = cityLocation + cityInfo.setTransients(civInfo) + + cityInfo.name = generateNewCityName( + civInfo, + civInfo.gameInfo.civilizations.asSequence().filter { civ -> civ.isAlive() }.toSet(), + arrayListOf("New ", "Neo ", "Nova ", "Altera ") + ) ?: "City Without A Name" + + cityInfo.isOriginalCapital = civInfo.citiesCreated == 0 + if (cityInfo.isOriginalCapital) { + civInfo.hasEverOwnedOriginalCapital = true + // if you have some culture before the 1st city is found, you may want to adopt the 1st policy + civInfo.policies.shouldOpenPolicyPicker = true + } + civInfo.citiesCreated++ + + civInfo.cities = civInfo.cities.toMutableList().apply { add(cityInfo) } + + val startingEra = civInfo.gameInfo.gameParameters.startingEra + + addStartingBuildings(cityInfo, civInfo, startingEra) + + cityInfo.expansion.reset() + + cityInfo.tryUpdateRoadStatus() + + val tile = cityInfo.getCenterTile() + for (terrainFeature in tile.terrainFeatures.filter { + cityInfo.getRuleset().tileImprovements.containsKey( + "Remove $it" + ) + }) + tile.removeTerrainFeature(terrainFeature) + + tile.changeImprovement(null) + tile.improvementInProgress = null + + val ruleset = civInfo.gameInfo.ruleSet + cityInfo.workedTiles = hashSetOf() //reassign 1st working tile + + cityInfo.population.setPopulation(ruleset.eras[startingEra]!!.settlerPopulation) + + if (civInfo.religionManager.religionState == ReligionState.Pantheon) { + cityInfo.religion.addPressure( + civInfo.religionManager.religion!!.name, + 200 * cityInfo.population.population + ) + } + + cityInfo.population.autoAssignPopulation() + + // Update proximity rankings for all civs + for (otherCiv in civInfo.gameInfo.getAliveMajorCivs()) { + if (civInfo.getProximity(otherCiv) != Proximity.Neighbors) // unless already neighbors + civInfo.cache.updateProximity(otherCiv, + otherCiv.cache.updateProximity(civInfo)) + } + for (otherCiv in civInfo.gameInfo.getAliveCityStates()) { + if (civInfo.getProximity(otherCiv) != Proximity.Neighbors) // unless already neighbors + civInfo.cache.updateProximity(otherCiv, + otherCiv.cache.updateProximity(civInfo)) + } + + triggerCitiesSettledNearOtherCiv(cityInfo) + civInfo.gameInfo.cityDistances.setDirty() + + return cityInfo + } + + + /** + * Generates and returns a new city name for the [foundingCiv]. + * + * This method attempts to return the first unused city name of the [foundingCiv], taking used + * city names into consideration (including foreign cities). If that fails, it then checks + * whether the civilization has [UniqueType.BorrowsCityNames] and, if true, returns a borrowed + * name. Else, it repeatedly attaches one of the given [prefixes] to the list of names up to ten + * times until an unused name is successfully generated. If all else fails, null is returned. + * + * @param foundingCiv The civilization that founded this city. + * @param aliveCivs Every civilization currently alive. + * @param prefixes Prefixes to add when every base name is taken, ordered. + * @return A new city name in [String]. Null if failed to generate a name. + */ + private fun generateNewCityName( + foundingCiv: CivilizationInfo, + aliveCivs: Set, + prefixes: List + ): String? { + val usedCityNames: Set = + aliveCivs.asSequence().flatMap { civilization -> + civilization.cities.asSequence().map { city -> city.name } + }.toSet() + + // Attempt to return the first missing name from the list of city names + for (cityName in foundingCiv.nation.cities) { + if (cityName !in usedCityNames) return cityName + } + + // If all names are taken and this nation borrows city names, + // return a random borrowed city name + if (foundingCiv.hasUnique(UniqueType.BorrowsCityNames)) { + return borrowCityName(foundingCiv, aliveCivs, usedCityNames) + } + + // If the nation doesn't have the unique above, + // return the first missing name with an increasing number of prefixes attached + // TODO: Make prefixes moddable per nation? Support suffixes? + var candidate: String? + for (number in (1..10)) { + for (prefix in prefixes) { + val currentPrefix: String = prefix.repeat(number) + candidate = foundingCiv.nation.cities.firstOrNull { cityName -> + (currentPrefix + cityName) !in usedCityNames + } + if (candidate != null) return currentPrefix + candidate + } + } + + // If all else fails (by using some sort of rule set mod without city names), + return null + } + + /** + * Borrows a city name from another major civilization. + * + * @param foundingCiv The civilization that founded this city. + * @param aliveCivs Every civilization currently alive. + * @param usedCityNames Every city name that have already been taken. + * @return A new city named in [String]. Null if failed to generate a name. + */ + private fun borrowCityName( + foundingCiv: CivilizationInfo, + aliveCivs: Set, + usedCityNames: Set + ): String? { + val aliveMajorNations: Sequence = + aliveCivs.asSequence().filter { civ -> civ.isMajorCiv() }.map { civ -> civ.nation } + + /* + We take the last unused city name for each other major nation in this game, + skipping nations whose names are exhausted, + and choose a random one from that pool if it's not empty. + */ + val otherMajorNations: Sequence = + aliveMajorNations.filter { nation -> nation != foundingCiv.nation } + var newCityNames: Set = + otherMajorNations.mapNotNull { nation -> + nation.cities.lastOrNull { city -> city !in usedCityNames } + }.toSet() + if (newCityNames.isNotEmpty()) return newCityNames.random() + + // As per fandom wiki, once the names from the other nations in the game are exhausted, + // names are taken from the rest of the major nations in the rule set + val absentMajorNations: Sequence = + foundingCiv.gameInfo.ruleSet.nations.values.asSequence().filter { nation -> + nation.isMajorCiv() && nation !in aliveMajorNations + } + newCityNames = + absentMajorNations.flatMap { nation -> + nation.cities.asSequence().filter { city -> city !in usedCityNames } + }.toSet() + if (newCityNames.isNotEmpty()) return newCityNames.random() + + // If for some reason we have used every single city name in the game, + // (are we using some sort of rule set mod without city names?) + return null + } + + + private fun addStartingBuildings(cityInfo: CityInfo, civInfo: CivilizationInfo, startingEra: String) { + val ruleset = civInfo.gameInfo.ruleSet + if (civInfo.cities.size == 1) cityInfo.cityConstructions.addBuilding(cityInfo.capitalCityIndicator()) + + // Add buildings and pop we get from starting in this era + for (buildingName in ruleset.eras[startingEra]!!.settlerBuildings) { + val building = ruleset.buildings[buildingName] ?: continue + val uniqueBuilding = civInfo.getEquivalentBuilding(building) + if (uniqueBuilding.isBuildable(cityInfo.cityConstructions)) + cityInfo.cityConstructions.addBuilding(uniqueBuilding.name) + } + + civInfo.civConstructions.tryAddFreeBuildings() + cityInfo.cityConstructions.addFreeBuildings() + } + + + /* + When someone settles a city within 6 tiles of another civ, this makes the AI unhappy and it starts a rolling event. + The SettledCitiesNearUs flag gets added to the AI so it knows this happened, + and on its turn it asks the player to stop (with a DemandToStopSettlingCitiesNear alert type) + If the player says "whatever, I'm not promising to stop", they get a -10 modifier which gradually disappears in 40 turns + If they DO agree, then if they keep their promise for ~100 turns they get a +10 modifier for keeping the promise, + But if they don't keep their promise they get a -20 that will only fully disappear in 160 turns. + There's a lot of triggering going on here. + */ + private fun triggerCitiesSettledNearOtherCiv(cityInfo: CityInfo) { + val citiesWithin6Tiles = + cityInfo.civInfo.gameInfo.civilizations.asSequence() + .filter { it.isMajorCiv() && it != cityInfo.civInfo } + .flatMap { it.cities } + .filter { it.getCenterTile().aerialDistanceTo(cityInfo.getCenterTile()) <= 6 } + val civsWithCloseCities = + citiesWithin6Tiles + .map { it.civInfo } + .distinct() + .filter { it.knows(cityInfo.civInfo) && it.hasExplored(cityInfo.location) } + for (otherCiv in civsWithCloseCities) + otherCiv.getDiplomacyManager(cityInfo.civInfo).setFlag(DiplomacyFlags.SettledCitiesNearUs, 30) + } +} diff --git a/core/src/com/unciv/logic/city/managers/CityTurnManager.kt b/core/src/com/unciv/logic/city/managers/CityTurnManager.kt new file mode 100644 index 0000000000..b64f53180e --- /dev/null +++ b/core/src/com/unciv/logic/city/managers/CityTurnManager.kt @@ -0,0 +1,147 @@ +package com.unciv.logic.city.managers + +import com.unciv.logic.city.CityFlags +import com.unciv.logic.city.CityFocus +import com.unciv.logic.city.CityInfo +import com.unciv.logic.civilization.NotificationCategory +import com.unciv.logic.civilization.NotificationIcon +import com.unciv.models.ruleset.tile.ResourceType +import com.unciv.models.ruleset.unique.UniqueType +import java.util.* +import kotlin.math.min + +class CityTurnManager(val cityInfo: CityInfo) { + + + fun startTurn() { + // Construct units at the beginning of the turn, + // so they won't be generated out in the open and vulnerable to enemy attacks before you can control them + cityInfo.cityConstructions.constructIfEnough() + cityInfo.cityConstructions.addFreeBuildings() + + cityInfo.cityStats.update() + cityInfo.tryUpdateRoadStatus() + cityInfo.attackedThisTurn = false + + if (cityInfo.isPuppet) { + cityInfo.cityAIFocus = CityFocus.GoldFocus + cityInfo.reassignAllPopulation() + } else if (cityInfo.updateCitizens) { + cityInfo.reassignPopulation() + cityInfo.updateCitizens = false + } + + // The ordering is intentional - you get a turn without WLTKD even if you have the next resource already + if (!cityInfo.hasFlag(CityFlags.WeLoveTheKing)) + tryWeLoveTheKing() + nextTurnFlags() + + // Seed resource demand countdown + if (cityInfo.demandedResource == "" && !cityInfo.hasFlag(CityFlags.ResourceDemand)) { + cityInfo.setFlag( + CityFlags.ResourceDemand, + (if (cityInfo.isCapital()) 25 else 15) + Random().nextInt(10)) + } + } + + private fun tryWeLoveTheKing() { + if (cityInfo.demandedResource == "") return + if (cityInfo.civInfo.getCivResourcesByName()[cityInfo.demandedResource]!! > 0) { + cityInfo.setFlag(CityFlags.WeLoveTheKing, 20 + 1) // +1 because it will be decremented by 1 in the same startTurn() + cityInfo.civInfo.addNotification( + "Because they have [${cityInfo.demandedResource}], the citizens of [${cityInfo.name}] are celebrating We Love The King Day!", + cityInfo.location, NotificationCategory.General, NotificationIcon.City, NotificationIcon.Happiness) + } + } + + // cf DiplomacyManager nextTurnFlags + private fun nextTurnFlags() { + for (flag in cityInfo.flagsCountdown.keys.toList()) { + if (cityInfo.flagsCountdown[flag]!! > 0) + cityInfo.flagsCountdown[flag] = cityInfo.flagsCountdown[flag]!! - 1 + + if (cityInfo.flagsCountdown[flag] == 0) { + cityInfo.flagsCountdown.remove(flag) + + when (flag) { + CityFlags.ResourceDemand.name -> { + demandNewResource() + } + CityFlags.WeLoveTheKing.name -> { + cityInfo.civInfo.addNotification( + "We Love The King Day in [${cityInfo.name}] has ended.", + cityInfo.location, NotificationCategory.General, NotificationIcon.City) + demandNewResource() + } + CityFlags.Resistance.name -> { + cityInfo.civInfo.addNotification( + "The resistance in [${cityInfo.name}] has ended!", + cityInfo.location, NotificationCategory.General, "StatIcons/Resistance") + } + } + } + } + } + + + private fun demandNewResource() { + val candidates = cityInfo.getRuleset().tileResources.values.filter { + it.resourceType == ResourceType.Luxury && // Must be luxury + !it.hasUnique(UniqueType.CityStateOnlyResource) && // Not a city-state only resource eg jewelry + it.name != cityInfo.demandedResource && // Not same as last time + !cityInfo.civInfo.hasResource(it.name) && // Not one we already have + it.name in cityInfo.tileMap.resources && // Must exist somewhere on the map + cityInfo.getCenterTile().getTilesInDistance(3).none { nearTile -> nearTile.resource == it.name } // Not in this city's radius + } + + val chosenResource = candidates.randomOrNull() + /* What if we had a WLTKD before but now the player has every resource in the game? We can't + pick a new resource, so the resource will stay stay the same and the city will demand it + again even if the player still has it. But we shouldn't punish success. */ + if (chosenResource != null) + cityInfo.demandedResource = chosenResource.name + if (cityInfo.demandedResource == "") // Failed to get a valid resource, try again some time later + cityInfo.setFlag(CityFlags.ResourceDemand, 15 + Random().nextInt(10)) + else + cityInfo.civInfo.addNotification("[${cityInfo.name}] demands [${cityInfo.demandedResource}]!", + cityInfo.location, NotificationCategory.General, NotificationIcon.City, "ResourceIcons/${cityInfo.demandedResource}") + } + + + fun endTurn() { + val stats = cityInfo.cityStats.currentCityStats + + cityInfo.cityConstructions.endTurn(stats) + cityInfo.expansion.nextTurn(stats.culture) + if (cityInfo.isBeingRazed) { + val removedPopulation = + 1 + cityInfo.civInfo.getMatchingUniques(UniqueType.CitiesAreRazedXTimesFaster) + .sumOf { it.params[0].toInt() - 1 } + cityInfo.population.addPopulation(-1 * removedPopulation) + + if (cityInfo.population.population <= 0) { + cityInfo.civInfo.addNotification( + "[${cityInfo.name}] has been razed to the ground!", + cityInfo.location, NotificationCategory.General, + "OtherIcons/Fire" + ) + cityInfo.destroyCity() + } else { //if not razed yet: + if (cityInfo.population.foodStored >= cityInfo.population.getFoodToNextPopulation()) { //if surplus in the granary... + cityInfo.population.foodStored = + cityInfo.population.getFoodToNextPopulation() - 1 //...reduce below the new growth threshold + } + } + } else cityInfo.population.nextTurn(cityInfo.foodForNextTurn()) + + // This should go after the population change, as that might impact the amount of followers in this city + if (cityInfo.civInfo.gameInfo.isReligionEnabled()) cityInfo.religion.endTurn() + + if (cityInfo in cityInfo.civInfo.cities) { // city was not destroyed + cityInfo.health = min(cityInfo.health + 20, cityInfo.getMaxHealth()) + cityInfo.population.unassignExtraPopulation() + } + } + + +} diff --git a/core/src/com/unciv/logic/civilization/CivilizationInfo.kt b/core/src/com/unciv/logic/civilization/CivilizationInfo.kt index 0b2b26b56b..794012540b 100644 --- a/core/src/com/unciv/logic/civilization/CivilizationInfo.kt +++ b/core/src/com/unciv/logic/civilization/CivilizationInfo.kt @@ -10,6 +10,7 @@ import com.unciv.logic.UncivShowableException import com.unciv.logic.automation.ai.TacticalAI import com.unciv.logic.automation.unit.WorkerAutomation import com.unciv.logic.city.CityInfo +import com.unciv.logic.city.managers.CityFounder import com.unciv.logic.civilization.diplomacy.CityStateFunctions import com.unciv.logic.civilization.diplomacy.CityStatePersonality import com.unciv.logic.civilization.diplomacy.DiplomacyFunctions @@ -625,40 +626,22 @@ class CivilizationInfo : IsPartOfGameInfoSerialization { fun setTransients() { goldenAges.civInfo = this greatPeople.civInfo = this - civConstructions.setTransients(civInfo = this) - - policies.civInfo = this - if (policies.adoptedPolicies.size > 0 && policies.numberOfAdoptedPolicies == 0) - policies.numberOfAdoptedPolicies = policies.adoptedPolicies.count { !Policy.isBranchCompleteByName(it) } - policies.setTransients() - - questManager.civInfo = this - questManager.setTransients() - - if (citiesCreated == 0 && cities.any()) - citiesCreated = cities.filter { it.name in nation.cities }.size - - religionManager.civInfo = this // needs to be before tech, since tech setTransients looks at all uniques - religionManager.setTransients() - - tech.civInfo = this - tech.setTransients() - + policies.setTransients(this) + questManager.setTransients(this) + religionManager.setTransients(this) // needs to be before tech, since tech setTransients looks at all uniques + tech.setTransients(this) ruinsManager.setTransients(this) + espionageManager.setTransients(this) + victoryManager.civInfo = this for (diplomacyManager in diplomacy.values) { diplomacyManager.civInfo = this diplomacyManager.updateHasOpenBorders() } - espionageManager.setTransients(this) - - victoryManager.civInfo = this - for (cityInfo in cities) { - cityInfo.civInfo = this // must be before the city's setTransients because it depends on the tilemap, that comes from the currentPlayerCivInfo - cityInfo.setTransients() + cityInfo.setTransients(this) // must be before the city's setTransients because it depends on the tilemap, that comes from the currentPlayerCivInfo } // Now that all tile transients have been updated, clean "worked" tiles that are not under the Civ's control @@ -742,7 +725,6 @@ class CivilizationInfo : IsPartOfGameInfoSerialization { } } - fun addNotification(text: String, location: Vector2, category:NotificationCategory, vararg notificationIcons: String) { addNotification(text, LocationAction(location), category, *notificationIcons) } @@ -757,7 +739,7 @@ class CivilizationInfo : IsPartOfGameInfoSerialization { } fun addCity(location: Vector2) { - val newCity = CityInfo(this, location) + val newCity = CityFounder().foundCity(this, location) newCity.cityConstructions.chooseNextConstruction() } diff --git a/core/src/com/unciv/logic/civilization/managers/PolicyManager.kt b/core/src/com/unciv/logic/civilization/managers/PolicyManager.kt index 5fab2010ea..0bea198f1e 100644 --- a/core/src/com/unciv/logic/civilization/managers/PolicyManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/PolicyManager.kt @@ -97,7 +97,8 @@ class PolicyManager : IsPartOfGameInfoSerialization { @Suppress("MemberVisibilityCanBePrivate") fun getPolicyByName(name: String): Policy = getRulesetPolicies()[name]!! - fun setTransients() { + fun setTransients(civInfo: CivilizationInfo) { + this.civInfo = civInfo for (policyName in adoptedPolicies) addPolicyToTransients( getPolicyByName(policyName) ) diff --git a/core/src/com/unciv/logic/civilization/managers/QuestManager.kt b/core/src/com/unciv/logic/civilization/managers/QuestManager.kt index f318dab93a..95c97537aa 100644 --- a/core/src/com/unciv/logic/civilization/managers/QuestManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/QuestManager.kt @@ -100,7 +100,8 @@ class QuestManager : IsPartOfGameInfoSerialization { return toReturn } - fun setTransients() { + fun setTransients(civInfo: CivilizationInfo) { + this.civInfo = civInfo for (quest in assignedQuests) quest.gameInfo = civInfo.gameInfo } diff --git a/core/src/com/unciv/logic/civilization/managers/ReligionManager.kt b/core/src/com/unciv/logic/civilization/managers/ReligionManager.kt index 0190f77dd6..5053524d0a 100644 --- a/core/src/com/unciv/logic/civilization/managers/ReligionManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/ReligionManager.kt @@ -60,7 +60,8 @@ class ReligionManager : IsPartOfGameInfoSerialization { return clone } - fun setTransients() { + fun setTransients(civInfo: CivilizationInfo) { + this.civInfo = civInfo // Find our religion from the map of founded religions. // First check if there is any major religion religion = civInfo.gameInfo.religions.values.firstOrNull { diff --git a/core/src/com/unciv/logic/civilization/managers/TechManager.kt b/core/src/com/unciv/logic/civilization/managers/TechManager.kt index 700e7dadae..a19a0bd43a 100644 --- a/core/src/com/unciv/logic/civilization/managers/TechManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/TechManager.kt @@ -418,7 +418,8 @@ class TechManager : IsPartOfGameInfoSerialization { techUniques.addUniques(tech.uniqueObjects) } - fun setTransients() { + fun setTransients(civInfo: CivilizationInfo) { + this.civInfo = civInfo researchedTechnologies.addAll(techsResearched.map { getRuleset().technologies[it]!! }) researchedTechnologies.forEach { addTechToTransients(it) } updateEra() // before updateTransientBooleans so era-based conditionals can work diff --git a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt index eb6eec88d1..d12584d5e3 100644 --- a/core/src/com/unciv/logic/civilization/managers/TurnManager.kt +++ b/core/src/com/unciv/logic/civilization/managers/TurnManager.kt @@ -3,6 +3,7 @@ package com.unciv.logic.civilization.managers import com.unciv.UncivGame import com.unciv.logic.VictoryData import com.unciv.logic.automation.civilization.NextTurnAutomation +import com.unciv.logic.city.managers.CityTurnManager import com.unciv.logic.civilization.AlertType import com.unciv.logic.civilization.CivFlags import com.unciv.logic.civilization.CivilizationInfo @@ -48,7 +49,7 @@ class TurnManager(val civInfo: CivilizationInfo) { civInfo.cache.updateCitiesConnectedToCapital() startTurnFlags() updateRevolts() - for (city in civInfo.cities) city.startTurn() // Most expensive part of startTurn + for (city in civInfo.cities) CityTurnManager(city).startTurn() // Most expensive part of startTurn for (unit in civInfo.units.getCivUnits()) unit.startTurn() @@ -257,7 +258,7 @@ class TurnManager(val civInfo: CivilizationInfo) { yieldAll(civInfo.cities.filter { it.isBeingRazed }) yieldAll(civInfo.cities.filterNot { it.isBeingRazed }) }.toList()) { // a city can be removed while iterating (if it's being razed) so we need to iterate over a copy - city.endTurn() + CityTurnManager(city).endTurn() } civInfo.temporaryUniques.endTurn() diff --git a/tests/src/com/unciv/logic/civilization/CapitalConnectionsFinderTests.kt b/tests/src/com/unciv/logic/civilization/CapitalConnectionsFinderTests.kt index fbbbad62c1..246f44bf02 100644 --- a/tests/src/com/unciv/logic/civilization/CapitalConnectionsFinderTests.kt +++ b/tests/src/com/unciv/logic/civilization/CapitalConnectionsFinderTests.kt @@ -9,9 +9,9 @@ import com.unciv.logic.civilization.transients.CapitalConnectionsFinder import com.unciv.logic.map.RoadStatus import com.unciv.logic.map.TileInfo import com.unciv.logic.map.TileMap -import com.unciv.models.ruleset.nation.Nation import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.ruleset.nation.Nation import com.unciv.models.ruleset.tile.TerrainType import com.unciv.models.ruleset.unique.UniqueType import com.unciv.testing.GdxTestRunner @@ -113,14 +113,13 @@ class CapitalConnectionsFinderTests { private fun createCity(civInfo: CivilizationInfo, position: Vector2, name: String, capital: Boolean = false, hasHarbor: Boolean = false): CityInfo { return CityInfo().apply { - this.civInfo = civInfo location = position if (capital) cityConstructions.builtBuildings.add(rules.buildings.values.first { it.hasUnique(UniqueType.IndicatesCapital) }.name) if (hasHarbor) cityConstructions.builtBuildings.add(rules.buildings.values.first { it.hasUnique(UniqueType.ConnectTradeRoutes) }.name) this.name = name - setTransients() + setTransients(civInfo) tilesMap[location].setOwningCity(this) } } diff --git a/tests/src/com/unciv/uniques/TestGame.kt b/tests/src/com/unciv/uniques/TestGame.kt index 85a61a7626..d807555210 100644 --- a/tests/src/com/unciv/uniques/TestGame.kt +++ b/tests/src/com/unciv/uniques/TestGame.kt @@ -5,6 +5,7 @@ import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.city.CityInfo +import com.unciv.logic.city.managers.CityFounder import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.civilization.PlayerType import com.unciv.logic.map.MapSizeNew @@ -18,12 +19,12 @@ import com.unciv.models.ruleset.Belief import com.unciv.models.ruleset.BeliefType import com.unciv.models.ruleset.Building import com.unciv.models.ruleset.IRulesetObject -import com.unciv.models.ruleset.nation.Nation import com.unciv.models.ruleset.Policy import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.Specialist import com.unciv.models.ruleset.Speed +import com.unciv.models.ruleset.nation.Nation import com.unciv.models.ruleset.tile.TileImprovement import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unit.BaseUnit @@ -133,7 +134,7 @@ class TestGame { replacePalace: Boolean = false, initialPopulation: Int = 0 ): CityInfo { - val cityInfo = CityInfo(civInfo, tile.position) + val cityInfo = CityFounder().foundCity(civInfo, tile.position) if (initialPopulation != 1) cityInfo.population.addPopulation(initialPopulation - 1) // With defaults this will remove population