From 8aeae300501e7d1d03692476642a793e6ca8aed5 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:27:32 +0200 Subject: [PATCH] Prevent selling free buildings (#10094) * A few yield extensions - use in existing code to do later * Refactor getFreeBuildings to allow hasFreeBuilding not enumerating all * Prevent selling free buildings - with a little easter egg * Test translatability * Shift "Free Building" methods towards preferring object parameters * Remove easter egg * Linting and improving Kdoc precision * Linting and improving Kdoc precision: CityConstructions --- .../civilization/NextTurnAutomation.kt | 2 +- core/src/com/unciv/logic/city/City.kt | 2 +- .../com/unciv/logic/city/CityConstructions.kt | 15 ++-- .../city/managers/CityConquestFunctions.kt | 2 +- .../logic/civilization/CivConstructions.kt | 80 ++++++++++++------- core/src/com/unciv/models/ruleset/Building.kt | 4 +- .../extensions/CollectionExtensions.kt | 15 ++++ .../unciv/ui/screens/cityscreen/CityScreen.kt | 4 + .../ui/screens/cityscreen/CityStatsTable.kt | 2 +- .../cityscreen/ConstructionInfoTable.kt | 44 ++++++---- 10 files changed, 111 insertions(+), 59 deletions(-) diff --git a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt index 168b835770..2d60860b03 100644 --- a/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt +++ b/core/src/com/unciv/logic/automation/civilization/NextTurnAutomation.kt @@ -622,7 +622,7 @@ object NextTurnAutomation { city.cityConstructions.isBuilt(it.name) && it.requiresResource(resource) && it.isSellable() - && it.name !in civInfo.civConstructions.getFreeBuildings(city.id) } + && !civInfo.civConstructions.hasFreeBuilding(city, it) } .randomOrNull() if (buildingToSell != null) { city.sellBuilding(buildingToSell) diff --git a/core/src/com/unciv/logic/city/City.kt b/core/src/com/unciv/logic/city/City.kt index f33c62d172..4aad98f03f 100644 --- a/core/src/com/unciv/logic/city/City.kt +++ b/core/src/com/unciv/logic/city/City.kt @@ -255,7 +255,7 @@ class City : IsPartOfGameInfoSerialization { } private fun manageCityResourcesRequiredByBuildings(cityResources: ResourceSupplyList) { - val freeBuildings = civ.civConstructions.getFreeBuildings(id) + val freeBuildings = civ.civConstructions.getFreeBuildingNames(this) for (building in cityConstructions.getBuiltBuildings()) { // Free buildings cost no resources if (building.name in freeBuildings) continue diff --git a/core/src/com/unciv/logic/city/CityConstructions.kt b/core/src/com/unciv/logic/city/CityConstructions.kt index 4cd5dccb07..83cf37e19b 100644 --- a/core/src/com/unciv/logic/city/CityConstructions.kt +++ b/core/src/com/unciv/logic/city/CityConstructions.kt @@ -79,7 +79,9 @@ class CityConstructions : IsPartOfGameInfoSerialization { var productionOverflow = 0 private val queueMaxSize = 10 - // Maps cities to the buildings they received + /** Maps cities by id to a set of the buildings they received (by nation equivalent name) + * Source: [UniqueType.GainFreeBuildings] + */ val freeBuildingsProvidedFromThisCity: HashMap> = hashMapOf() //endregion @@ -122,7 +124,7 @@ class CityConstructions : IsPartOfGameInfoSerialization { */ fun getMaintenanceCosts(): Int { var maintenanceCost = 0 - val freeBuildings = city.civ.civConstructions.getFreeBuildings(city.id) + val freeBuildings = city.civ.civConstructions.getFreeBuildingNames(city) for (building in getBuiltBuildings()) if (building.name !in freeBuildings) @@ -602,10 +604,7 @@ class CityConstructions : IsPartOfGameInfoSerialization { for (city in citiesThatApply) { if (city.cityConstructions.containsBuildingOrEquivalent(freeBuilding.name)) continue city.cityConstructions.addBuilding(freeBuilding) - if (city.id !in freeBuildingsProvidedFromThisCity) - freeBuildingsProvidedFromThisCity[city.id] = hashSetOf() - - freeBuildingsProvidedFromThisCity[city.id]!!.add(freeBuilding.name) + freeBuildingsProvidedFromThisCity.getOrPut(city.id) { hashSetOf() }.add(freeBuilding.name) } } @@ -613,9 +612,7 @@ class CityConstructions : IsPartOfGameInfoSerialization { for (unique in city.civ.getMatchingUniques(UniqueType.GainFreeBuildings, stateForConditionals = StateForConditionals(city.civ, city))) { val freeBuilding = city.civ.getEquivalentBuilding(unique.params[0]) if (city.matchesFilter(unique.params[1])) { - if (city.id !in freeBuildingsProvidedFromThisCity) - freeBuildingsProvidedFromThisCity[city.id] = hashSetOf() - freeBuildingsProvidedFromThisCity[city.id]!!.add(freeBuilding.name) + freeBuildingsProvidedFromThisCity.getOrPut(city.id) { hashSetOf() }.add(freeBuilding.name) if (!isBuilt(freeBuilding.name)) addBuilding(freeBuilding) } diff --git a/core/src/com/unciv/logic/city/managers/CityConquestFunctions.kt b/core/src/com/unciv/logic/city/managers/CityConquestFunctions.kt index 0521624eb9..a481aaf38c 100644 --- a/core/src/com/unciv/logic/city/managers/CityConquestFunctions.kt +++ b/core/src/com/unciv/logic/city/managers/CityConquestFunctions.kt @@ -50,7 +50,7 @@ class CityConquestFunctions(val city: City){ private fun removeBuildingsOnMoveToCiv(oldCiv: Civilization) { // Remove all buildings provided for free to this city - for (building in city.civ.civConstructions.getFreeBuildings(city.id)) { + for (building in city.civ.civConstructions.getFreeBuildingNames(city)) { city.cityConstructions.removeBuilding(building) } diff --git a/core/src/com/unciv/logic/civilization/CivConstructions.kt b/core/src/com/unciv/logic/civilization/CivConstructions.kt index be560a6636..c86c40ddd0 100644 --- a/core/src/com/unciv/logic/civilization/CivConstructions.kt +++ b/core/src/com/unciv/logic/civilization/CivConstructions.kt @@ -1,46 +1,53 @@ package com.unciv.logic.civilization import com.unciv.logic.IsPartOfGameInfoSerialization +import com.unciv.logic.city.City import com.unciv.models.Counter import com.unciv.models.ruleset.Building import com.unciv.models.ruleset.INonPerpetualConstruction import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.stats.Stat +import com.unciv.ui.components.extensions.yieldAllNotNull class CivConstructions : IsPartOfGameInfoSerialization { @Transient lateinit var civInfo: Civilization - // Maps objects to the amount of times bought + /** Maps construction names to the amount of times bought */ val boughtItemsWithIncreasingPrice: Counter = Counter() - // Maps to cities to all free buildings they contain + /** Maps cities by id to a set of all free buildings by name they contain. + * The building name is the Nation-specific equivalent if available. + * Sources: [UniqueType.FreeStatBuildings] **and** [UniqueType.FreeSpecificBuildings] + * This is persisted and _never_ cleared or elements removed (per civ and game). + */ private val freeBuildings: HashMap> = hashMapOf() - // Maps stats to the cities that have received a building of that stat - // We can't use an enum instead of a string, due to the inability of the JSON-parser + /** Maps stat names to a set of cities by id that have received a building of that stat. + * Source: [UniqueType.FreeStatBuildings] + * This is persisted and _never_ cleared or elements removed (per civ and game). + */ + // We can't use the Stat enum instead of a string, due to the inability of the JSON-parser // to function properly and forcing this to be an `HashMap>` // when loading, even if this wasn't the original type, leading to run-time errors. private val freeStatBuildingsProvided: HashMap> = hashMapOf() - // Maps buildings to the cities that have received that building + /** Maps buildings by name to a set of cities by id that have received that building. + * The building name is the Nation-specific equivalent if available. + * Source: [UniqueType.FreeSpecificBuildings] + * This is persisted and _never_ cleared or elements removed (per civ and game). + */ private val freeSpecificBuildingsProvided: HashMap> = hashMapOf() - init { - for (stat in Stat.values()) { - freeStatBuildingsProvided[stat.name] = hashSetOf() - } - } - fun clone(): CivConstructions { val toReturn = CivConstructions() toReturn.civInfo = civInfo toReturn.freeBuildings.putAll(freeBuildings) toReturn.freeStatBuildingsProvided.putAll(freeStatBuildingsProvided) toReturn.freeSpecificBuildingsProvided.putAll(freeSpecificBuildingsProvided) - toReturn.boughtItemsWithIncreasingPrice.add(boughtItemsWithIncreasingPrice.clone()) + toReturn.boughtItemsWithIncreasingPrice.add(boughtItemsWithIncreasingPrice) // add copies return toReturn } @@ -57,21 +64,32 @@ class CivConstructions : IsPartOfGameInfoSerialization { addFreeSpecificBuildings() } - fun getFreeBuildings(cityId: String): HashSet { - val toReturn = freeBuildings[cityId] ?: hashSetOf() + /** Common to [hasFreeBuildingByName] and [getFreeBuildingNames] - 'has' doesn't need the whole set, one enumeration is enough. + * Note: Operates on String city.id and String building name, close to the serialized and stored form. + * When/if we do a transient cache for these using our objects, please rewrite this. + */ + private fun getFreeBuildingNamesSequence(cityId: String) = sequence { + yieldAllNotNull(freeBuildings[cityId]) for (city in civInfo.cities) { - val freeBuildingsProvided = - city.cityConstructions.freeBuildingsProvidedFromThisCity[cityId] - if (freeBuildingsProvided != null) - toReturn.addAll(freeBuildingsProvided) + yieldAllNotNull(city.cityConstructions.freeBuildingsProvidedFromThisCity[cityId]) } - return toReturn } + /** Gets a Set of all building names the [city] has for free, from nationwide sources or buildings in other cities */ + fun getFreeBuildingNames(city: City) = + getFreeBuildingNamesSequence(city.id).toSet() + + /** Tests whether the [city] has [building] for free, from nationwide sources or buildings in other cities */ + fun hasFreeBuilding(city: City, building: Building) = + hasFreeBuildingByName(city.id, building.name) + + /** Tests whether a city by [cityId] has a building named [buildingName] for free, from nationwide sources or buildings in other cities */ + private fun hasFreeBuildingByName(cityId: String, buildingName: String) = + getFreeBuildingNamesSequence(cityId).contains(buildingName) + private fun addFreeBuilding(cityId: String, building: String) { - if (!freeBuildings.containsKey(cityId)) - freeBuildings[cityId] = hashSetOf() - freeBuildings[cityId]!!.add(civInfo.getEquivalentBuilding(building).name) + freeBuildings.getOrPut(cityId) { hashSetOf() } + .add(civInfo.getEquivalentBuilding(building).name) } private fun addFreeStatsBuildings() { @@ -79,7 +97,6 @@ class CivConstructions : IsPartOfGameInfoSerialization { .groupBy { it.params[0] } .mapKeys { Stat.valueOf(it.key) } .mapValues { unique -> unique.value.sumOf { it.params[1].toInt() } } - .toMutableMap() for ((stat, amount) in statUniquesData) { addFreeStatBuildings(stat, amount) @@ -88,11 +105,12 @@ class CivConstructions : IsPartOfGameInfoSerialization { private fun addFreeStatBuildings(stat: Stat, amount: Int) { for (city in civInfo.cities.take(amount)) { - if (freeStatBuildingsProvided[stat.name]!!.contains(city.id) || !city.cityConstructions.hasBuildableStatBuildings(stat)) continue + if (freeStatBuildingsProvided[stat.name]?.contains(city.id) == true) continue + if (!city.cityConstructions.hasBuildableStatBuildings(stat)) continue val builtBuilding = city.cityConstructions.addCheapestBuildableStatBuilding(stat) if (builtBuilding != null) { - freeStatBuildingsProvided[stat.name]!!.add(city.id) + freeStatBuildingsProvided.getOrPut(stat.name) { hashSetOf() }.add(city.id) addFreeBuilding(city.id, builtBuilding) } } @@ -117,14 +135,18 @@ class CivConstructions : IsPartOfGameInfoSerialization { building.postBuildEvent(city.cityConstructions) - if (!freeSpecificBuildingsProvided.containsKey(building.name)) - freeSpecificBuildingsProvided[building.name] = hashSetOf() - freeSpecificBuildingsProvided[building.name]!!.add(city.id) - + freeSpecificBuildingsProvided.getOrPut(building.name) { hashSetOf() }.add(city.id) addFreeBuilding(city.id, building.name) } } + /** Calculates a civ-wide total for [objectToCount]. + * + * It counts: + * * "Spaceship part" units added to "spaceship" in capital + * * Built buildings or those in a construction queue + * * Units on the map or being constructed + */ fun countConstructedObjects(objectToCount: INonPerpetualConstruction): Int { val amountInSpaceShip = civInfo.victoryManager.currentsSpaceshipParts[objectToCount.name] diff --git a/core/src/com/unciv/models/ruleset/Building.kt b/core/src/com/unciv/models/ruleset/Building.kt index 01d753301b..f77d740ff0 100644 --- a/core/src/com/unciv/models/ruleset/Building.kt +++ b/core/src/com/unciv/models/ruleset/Building.kt @@ -109,7 +109,7 @@ class Building : RulesetStatsObject(), INonPerpetualConstruction { fun getDescription(city: City, showAdditionalInfo: Boolean): String { val stats = getStats(city) val translatedLines = ArrayList() // Some translations require special handling - val isFree = name in city.civ.civConstructions.getFreeBuildings(city.id) + val isFree = city.civ.civConstructions.hasFreeBuilding(city, this) if (uniqueTo != null) translatedLines += if (replaces == null) "Unique to [$uniqueTo]".tr() else "Unique to [$uniqueTo], replaces [$replaces]".tr() val missingUnique = getMatchingUniques(UniqueType.RequiresBuildingInAllCities).firstOrNull() @@ -467,7 +467,7 @@ class Building : RulesetStatsObject(), INonPerpetualConstruction { for (unique in uniqueObjects) { if (unique.type != UniqueType.OnlyAvailableWhen && !unique.conditionalsApply(StateForConditionals(civ, cityConstructions.city))) continue - + @Suppress("NON_EXHAUSTIVE_WHEN") when (unique.type) { // for buildings that are created as side effects of other things, and not directly built, diff --git a/core/src/com/unciv/ui/components/extensions/CollectionExtensions.kt b/core/src/com/unciv/ui/components/extensions/CollectionExtensions.kt index b56ff70d19..611852749b 100644 --- a/core/src/com/unciv/ui/components/extensions/CollectionExtensions.kt +++ b/core/src/com/unciv/ui/components/extensions/CollectionExtensions.kt @@ -68,3 +68,18 @@ fun Iterable.toGdxArray(): Array { for (it in this) arr.add(it) return arr } + +/** [yield][SequenceScope.yield]s [element] if it's not null */ +suspend fun SequenceScope.yieldIfNotNull(element: T?) { + if (element != null) yield(element) +} +/** [yield][SequenceScope.yield]s all elements of [elements] if it's not null */ +suspend fun SequenceScope.yieldAllNotNull(elements: Iterable?) { + if (elements != null) yieldAll(elements) +} +@JvmName("yieldAllNotNullNotNull") +/** [yield][SequenceScope.yield]s all non-null elements of [elements] if it's not null */ +suspend fun SequenceScope.yieldAllNotNull(elements: Iterable?) { + if (elements == null) return + for (element in elements) yieldIfNotNull(element) +} diff --git a/core/src/com/unciv/ui/screens/cityscreen/CityScreen.kt b/core/src/com/unciv/ui/screens/cityscreen/CityScreen.kt index 75200dac62..104b24d6ad 100644 --- a/core/src/com/unciv/ui/screens/cityscreen/CityScreen.kt +++ b/core/src/com/unciv/ui/screens/cityscreen/CityScreen.kt @@ -440,6 +440,10 @@ class CityScreen( update() } + /** Convenience shortcut to [CivConstructions.hasFreeBuilding][com.unciv.logic.civilization.CivConstructions.hasFreeBuilding], nothing more */ + internal fun hasFreeBuilding(building: Building) = + city.civ.civConstructions.hasFreeBuilding(city, building) + fun selectConstruction(name: String) { selectConstruction(city.cityConstructions.getConstruction(name)) } diff --git a/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt b/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt index c5b2f090a9..4ba0923a0b 100644 --- a/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt +++ b/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt @@ -285,7 +285,7 @@ class CityStatsTable(private val cityScreen: CityScreen): Table() { val statsAndSpecialists = Table() val icon = ImageGetter.getConstructionPortrait(building.name, 50f) - val isFree = building.name in cityScreen.city.civ.civConstructions.getFreeBuildings(cityScreen.city.id) + val isFree = cityScreen.hasFreeBuilding(building) val displayName = if (isFree) "{${building.name}} ({Free})" else building.name info.add(displayName.toLabel(fontSize = Constants.defaultFontSize, hideIcons = true)).padBottom(5f).right().row() diff --git a/core/src/com/unciv/ui/screens/cityscreen/ConstructionInfoTable.kt b/core/src/com/unciv/ui/screens/cityscreen/ConstructionInfoTable.kt index d4f6495409..574092316d 100644 --- a/core/src/com/unciv/ui/screens/cityscreen/ConstructionInfoTable.kt +++ b/core/src/com/unciv/ui/screens/cityscreen/ConstructionInfoTable.kt @@ -16,6 +16,7 @@ import com.unciv.models.translations.tr import com.unciv.ui.components.Fonts import com.unciv.ui.components.extensions.darken import com.unciv.ui.components.extensions.disable +import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.input.onClick import com.unciv.ui.images.ImageGetter @@ -99,22 +100,15 @@ class ConstructionInfoTable(val cityScreen: CityScreen): Table() { row() add(sellBuildingButton).padTop(5f).colspan(2).center() - sellBuildingButton.onClick(UncivSound.Coin) { + val isFree = cityScreen.hasFreeBuilding(construction) + val enableSell = !isFree && + !cityScreen.city.isPuppet && + cityScreen.canChangeState && + (!cityScreen.city.hasSoldBuildingThisTurn || cityScreen.city.civ.gameInfo.gameParameters.godMode) + sellBuildingButton.isEnabled = enableSell + if (sellBuildingButton.isEnabled) sellBuildingButton.onClick(UncivSound.Coin) { sellBuildingButton.disable() - cityScreen.closeAllPopups() - - ConfirmPopup( - cityScreen, - "Are you sure you want to sell this [${construction.name}]?", - sellText, - restoreDefault = { - cityScreen.update() - } - ) { - cityScreen.city.sellBuilding(construction) - cityScreen.clearSelection() - cityScreen.update() - }.open() + sellBuildingClicked(construction, isFree, sellText) } if (cityScreen.city.hasSoldBuildingThisTurn && !cityScreen.city.civ.gameInfo.gameParameters.godMode @@ -125,4 +119,24 @@ class ConstructionInfoTable(val cityScreen: CityScreen): Table() { } } + private fun sellBuildingClicked(construction: Building, isFree: Boolean, sellText: String) { + cityScreen.closeAllPopups() + + ConfirmPopup( + cityScreen, + "Are you sure you want to sell this [${construction.name}]?", + sellText, + restoreDefault = { + cityScreen.update() + } + ) { + sellBuildingConfirmed(construction, isFree) + }.open() + } + + private fun sellBuildingConfirmed(construction: Building, isFree: Boolean) { + cityScreen.city.sellBuilding(construction) + cityScreen.clearSelection() + cityScreen.update() + } }