diff --git a/core/src/com/unciv/logic/city/City.kt b/core/src/com/unciv/logic/city/City.kt index b32dd8234f..f43c4af137 100644 --- a/core/src/com/unciv/logic/city/City.kt +++ b/core/src/com/unciv/logic/city/City.kt @@ -12,12 +12,10 @@ import com.unciv.logic.city.managers.CityPopulationManager import com.unciv.logic.city.managers.CityReligionManager import com.unciv.logic.city.managers.SpyFleeReason import com.unciv.logic.civilization.Civilization -import com.unciv.logic.civilization.diplomacy.DiplomacyFlags import com.unciv.logic.map.TileMap import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.tile.RoadStatus import com.unciv.logic.map.tile.Tile -import com.unciv.models.Counter import com.unciv.models.ruleset.Building import com.unciv.models.ruleset.unique.StateForConditionals import com.unciv.models.ruleset.unique.Unique @@ -216,69 +214,8 @@ class City : IsPartOfGameInfoSerialization { fun containsBuildingUnique(uniqueType: UniqueType) = cityConstructions.builtBuildingUniqueMap.getUniques(uniqueType).any() - fun getGreatPersonPercentageBonus(): Int{ - var allGppPercentageBonus = 0 - for (unique in getMatchingUniques(UniqueType.GreatPersonPointPercentage)) { - if (!matchesFilter(unique.params[1])) continue - allGppPercentageBonus += unique.params[0].toInt() - } - - // Sweden UP - for (otherCiv in civ.getKnownCivs()) { - if (!civ.getDiplomacyManager(otherCiv).hasFlag(DiplomacyFlags.DeclarationOfFriendship)) - continue - - for (ourUnique in civ.getMatchingUniques(UniqueType.GreatPersonBoostWithFriendship)) - allGppPercentageBonus += ourUnique.params[0].toInt() - for (theirUnique in otherCiv.getMatchingUniques(UniqueType.GreatPersonBoostWithFriendship)) - allGppPercentageBonus += theirUnique.params[0].toInt() - } - return allGppPercentageBonus - } - - fun getGreatPersonPointsForNextTurn(): HashMap> { - val sourceToGPP = HashMap>() - - val specialistsCounter = Counter() - for ((specialistName, amount) in population.getNewSpecialists()) - if (getRuleset().specialists.containsKey(specialistName)) { // To solve problems in total remake mods - val specialist = getRuleset().specialists[specialistName]!! - specialistsCounter.add(specialist.greatPersonPoints.times(amount)) - } - sourceToGPP["Specialists"] = specialistsCounter - - val buildingsCounter = Counter() - for (building in cityConstructions.getBuiltBuildings()) - buildingsCounter.add(building.greatPersonPoints) - sourceToGPP["Buildings"] = buildingsCounter - - val stateForConditionals = StateForConditionals(civInfo = civ, city = this) - for ((_, gppCounter) in sourceToGPP) { - for (unique in civ.getMatchingUniques(UniqueType.GreatPersonEarnedFaster, stateForConditionals)) { - val unitName = unique.params[0] - if (!gppCounter.containsKey(unitName)) continue - gppCounter.add(unitName, gppCounter[unitName] * unique.params[1].toInt() / 100) - } - - val allGppPercentageBonus = getGreatPersonPercentageBonus() - - for (unitName in gppCounter.keys) - gppCounter.add(unitName, gppCounter[unitName] * allGppPercentageBonus / 100) - } - - return sourceToGPP - } - - fun getGreatPersonPoints(): Counter { - val gppCounter = Counter() - for (entry in getGreatPersonPointsForNextTurn().values) - gppCounter.add(entry) - // Remove all "gpp" values that are not valid units - for (key in gppCounter.keys.toSet()) - if (key !in getRuleset().units) - gppCounter.remove(key) - return gppCounter - } + fun getGreatPersonPercentageBonus() = GreatPersonPointsBreakdown.getGreatPersonPercentageBonus(this) + fun getGreatPersonPoints() = GreatPersonPointsBreakdown(this).sum() fun addStat(stat: Stat, amount: Int) { when (stat) { diff --git a/core/src/com/unciv/logic/city/GreatPersonPointsBreakdown.kt b/core/src/com/unciv/logic/city/GreatPersonPointsBreakdown.kt new file mode 100644 index 0000000000..592bc79dc0 --- /dev/null +++ b/core/src/com/unciv/logic/city/GreatPersonPointsBreakdown.kt @@ -0,0 +1,158 @@ +package com.unciv.logic.city + +import com.unciv.logic.civilization.diplomacy.DiplomacyFlags +import com.unciv.models.Counter +import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.unique.StateForConditionals +import com.unciv.models.ruleset.unique.Unique +import com.unciv.models.ruleset.unique.UniqueType + +/** Manages calculating Great Person Points per City for nextTurn. See public constructor(city) below for details. */ +class GreatPersonPointsBreakdown private constructor(private val ruleset: Ruleset) { + // ruleset kept as class field for reuse in sum(), for the "Remove all gpp values that are not valid units" step. + // I am unsure why that existed before this class was written, what the UX for invalid mods should be. + // Refactoring through allNames so that ruleset is only needed in init should be easy if all UI should behave as if any invalid definition did not exist at all. + // As is, the "lost" points can still display. + + /** Return type component of the [Companion.getPercentagesApplyingToAllGP] helper */ + private class AllGPPercentageEntry( + val source: String, + val pediaLink: String?, + val bonus: Int + ) + + /** Represents any source of Great Person Points or GPP percentage bonuses */ + class Entry ( + /** Simple label for the source of these points */ + val source: String, + /** In case we want to show the breakdown with decorations and/or Civilopedia linking */ + val pediaLink: String? = null, + /** For display only - this entry affects all Great Persons and can be displayed as simple percentage without listing all GP keys */ + val isAllGP: Boolean = false, + /** Reference to the points, **do not mutate** */ + // To lift the mutability restriction, clone building.greatPersonPoints below - all others are already owned here + val counter: Counter = Counter() + ) + + companion object { + // Using fixed-point(n.3) math in sum() to avoid surprises by rounding while still leveraging the Counter class + const val fixedPointFactor = 1000 + + + private fun getUniqueSourceName(unique: Unique) = unique.sourceObjectName ?: "Bonus" + + private fun guessPediaLink(unique: Unique): String? { + if (unique.sourceObjectName == null) return null + return unique.sourceObjectType!!.name + "/" + unique.sourceObjectName + } + + /** List all percentage bonuses that apply to all GPP + * + * This is used internally from the public constructor to include them in the brakdown, + * and exposed to autoAssignPopulation via [getGreatPersonPercentageBonus] + */ + private fun getPercentagesApplyingToAllGP(city: City) = sequence { + // Now add boni for GreatPersonPointPercentage + for (unique in city.getMatchingUniques(UniqueType.GreatPersonPointPercentage)) { + if (!city.matchesFilter(unique.params[1])) continue + yield(AllGPPercentageEntry(getUniqueSourceName(unique), guessPediaLink(unique), unique.params[0].toInt())) + } + + // Now add boni for GreatPersonBoostWithFriendship (Sweden UP) + val civ = city.civ + for (otherCiv in civ.getKnownCivs()) { + if (!civ.getDiplomacyManager(otherCiv).hasFlag(DiplomacyFlags.DeclarationOfFriendship)) + continue + val boostUniques = civ.getMatchingUniques(UniqueType.GreatPersonBoostWithFriendship) + + otherCiv.getMatchingUniques(UniqueType.GreatPersonBoostWithFriendship) + for (unique in boostUniques) + yield(AllGPPercentageEntry("Declaration of Friendship", null, unique.params[0].toInt())) + } + } + + /** Aggregate all percentage bonuses that apply to all GPP + * + * For use by [City.getGreatPersonPercentageBonus] (which in turn is only used by autoAssignPopulation) + */ + fun getGreatPersonPercentageBonus(city: City) = getPercentagesApplyingToAllGP(city).sumOf { it.bonus } + } + + /** Collects all GPP names that have base points */ + val allNames = mutableSetOf() + + val basePoints = ArrayList() + val percentBonuses = ArrayList() + + /** Manages calculating Great Person Points per City for nextTurn. + * + * Keeps flat points and percentage boni as separate items. + * + * See [sum] to calculate the aggregate, use [basePoints] and [percentBonuses] to list as breakdown + */ + constructor(city: City) : this(city.getRuleset()) { + // Collect points from Specialists + val specialists = Entry("Specialists") // "Tutorial/Great People" as link doesn't quite fit + for ((specialistName, amount) in city.population.getNewSpecialists()) + if (ruleset.specialists.containsKey(specialistName)) { // To solve problems in total remake mods + val specialist = ruleset.specialists[specialistName]!! + specialists.counter.add(specialist.greatPersonPoints.times(amount)) + } + basePoints.add(specialists) + allNames += specialists.counter.keys + + // Collect points from buildings - duplicates listed individually (should not happen in vanilla Unciv) + for (building in city.cityConstructions.getBuiltBuildings()) { + if (building.greatPersonPoints.isEmpty()) continue + basePoints.add(Entry(building.name, building.makeLink(), counter = building.greatPersonPoints)) + allNames += building.greatPersonPoints.keys + } + + // Translate bonuses applying to all GP equally + for (item in getPercentagesApplyingToAllGP(city)) { + val bonusEntry = Entry(item.source, item.pediaLink, isAllGP = true) + for (name in allNames) + bonusEntry.counter.add(name, item.bonus) + percentBonuses.add(bonusEntry) + } + + // And last, the GPP-type-specific GreatPersonEarnedFaster Unique + val stateForConditionals = StateForConditionals(city) + for (unique in city.civ.getMatchingUniques(UniqueType.GreatPersonEarnedFaster, stateForConditionals)) { + val gppName = unique.params[0] + if (gppName !in allNames) continue // No sense applying a percentage without base points + val bonusEntry = Entry(getUniqueSourceName(unique), guessPediaLink(unique)) + bonusEntry.counter.add(gppName, unique.params[1].toInt()) + percentBonuses.add(bonusEntry) + } + } + + /** Aggregate over sources, applying percentage boni using fixed-point math to avoid rounding surprises */ + fun sum(): Counter { + // Accumulate base points as fake "fixed-point" + val result = Counter() + for (entry in basePoints) + result.add(entry.counter * fixedPointFactor) + + // Accumulate percentage bonuses additively not multiplicatively + val bonuses = Counter() + for (entry in percentBonuses) { + bonuses.add(entry.counter) + } + + // Apply percent bonuses + for (key in result.keys.filter { it in bonuses }) { + result.add(key, result[key] * bonuses[key] / 100) + } + + // Round fixed-point to integers, toSet() because a result of 0 will remove the entry (-99% bonus in a certain Mod) + for (key in result.keys.toSet()) + result[key] = (result[key] + fixedPointFactor / 2) / fixedPointFactor + + // Remove all "gpp" values that are not valid units + for (key in result.keys.toSet()) + if (key !in ruleset.units) + result.remove(key) + + return result + } +} diff --git a/core/src/com/unciv/logic/city/managers/CityPopulationManager.kt b/core/src/com/unciv/logic/city/managers/CityPopulationManager.kt index 276c3e6d6b..8ea91a7830 100644 --- a/core/src/com/unciv/logic/city/managers/CityPopulationManager.kt +++ b/core/src/com/unciv/logic/city/managers/CityPopulationManager.kt @@ -146,7 +146,7 @@ class CityPopulationManager : IsPartOfGameInfoSerialization { internal fun autoAssignPopulation() { city.cityStats.update() // calculate current stats with current assignments val cityStats = city.cityStats.currentCityStats - city.currentGPPBonus = city.getGreatPersonPercentageBonus() // pre-calculate + city.currentGPPBonus = city.getGreatPersonPercentageBonus() // pre-calculate for use in Automation.rankSpecialist var specialistFoodBonus = 2f // See CityStats.calcFoodEaten() for (unique in city.getMatchingUniques(UniqueType.FoodConsumptionBySpecialists)) if (city.matchesFilter(unique.params[1])) diff --git a/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt b/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt index cb8964f28f..776605e3ea 100644 --- a/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt +++ b/core/src/com/unciv/ui/screens/cityscreen/CityStatsTable.kt @@ -11,6 +11,7 @@ import com.unciv.UncivGame import com.unciv.logic.city.City import com.unciv.logic.city.CityFlags import com.unciv.logic.city.CityFocus +import com.unciv.logic.city.GreatPersonPointsBreakdown import com.unciv.models.Counter import com.unciv.models.ruleset.Building import com.unciv.models.ruleset.tile.TileResource @@ -350,21 +351,14 @@ class CityStatsTable(private val cityScreen: CityScreen) : Table() { val greatPeopleTable = Table() - val greatPersonPoints = city.getGreatPersonPointsForNextTurn() - val allGreatPersonNames = greatPersonPoints.asSequence().flatMap { it.value.keys }.distinct() - - if (allGreatPersonNames.none()) + val gppBreakdown = GreatPersonPointsBreakdown(city) + if (gppBreakdown.allNames.isEmpty()) return + val greatPersonPoints = gppBreakdown.sum() - for (greatPersonName in allGreatPersonNames) { - - var gppPerTurn = 0 - - for ((_, gppCounter) in greatPersonPoints) { - val gppPointsFromSource = gppCounter[greatPersonName] - if (gppPointsFromSource == 0) continue - gppPerTurn += gppPointsFromSource - } + // Iterating over allNames instead of greatPersonPoints will include those where the aggregation had points but ended up zero + for (greatPersonName in gppBreakdown.allNames) { + val gppPerTurn = greatPersonPoints[greatPersonName] val info = Table() @@ -391,9 +385,15 @@ class CityStatsTable(private val cityScreen: CityScreen) : Table() { progressBar.setLabel(Color.WHITE, "$gppCurrent/$gppNeeded", fontSize = 14) info.add(progressBar).colspan(2).left().expandX().row() - + info.onClick { + GreatPersonPointsBreakdownPopup(cityScreen, gppBreakdown, greatPersonName) + } greatPeopleTable.add(info).growX().top().padBottom(10f) - greatPeopleTable.add(ImageGetter.getConstructionPortrait(greatPersonName, 50f)).row() + val icon = ImageGetter.getConstructionPortrait(greatPersonName, 50f) + icon.onClick { + GreatPersonPointsBreakdownPopup(cityScreen, gppBreakdown, null) + } + greatPeopleTable.add(icon).row() } lowerTable.addCategory("Great People", greatPeopleTable, KeyboardBinding.GreatPeopleDetail) diff --git a/core/src/com/unciv/ui/screens/cityscreen/GreatPersonPointsBreakdownPopup.kt b/core/src/com/unciv/ui/screens/cityscreen/GreatPersonPointsBreakdownPopup.kt new file mode 100644 index 0000000000..9fc6da8e30 --- /dev/null +++ b/core/src/com/unciv/ui/screens/cityscreen/GreatPersonPointsBreakdownPopup.kt @@ -0,0 +1,59 @@ +package com.unciv.ui.screens.cityscreen + +import com.unciv.logic.city.GreatPersonPointsBreakdown +import com.unciv.ui.components.extensions.toStringSigned +import com.unciv.ui.popups.Popup +import com.unciv.ui.screens.civilopediascreen.CivilopediaScreen +import com.unciv.ui.screens.civilopediascreen.FormattedLine +import com.unciv.ui.screens.civilopediascreen.MarkupRenderer + +class GreatPersonPointsBreakdownPopup(cityScreen: CityScreen, gppBreakdown: GreatPersonPointsBreakdown, greatPerson: String?) : Popup(cityScreen) { + init { + val lines = ArrayList() + val headerText = "«GOLD»{${greatPerson ?: "Great person points"}}«» ({${cityScreen.city.name}})" + lines += FormattedLine(headerText, header = 2, centered = true) + lines += FormattedLine(separator = true) + + fun addFormattedEntry(entry: GreatPersonPointsBreakdown.Entry, isPercentage: Boolean) { + val text = if (greatPerson == null) { + // Popup shows all GP for a city - this will resolve the counters if necessary and dhow GP names from the keys + entry.toString(isPercentage) + } else { + // Popup shows only a specific GP - check counters directly + val amount = entry.counter[greatPerson] + if (amount == 0) return + // Formatter does not need the GP name as in all cases the one in the header is clear enough + entry.toString(isPercentage, amount) + } + lines += FormattedLine(text, entry.pediaLink ?: "") + } + + for (entry in gppBreakdown.basePoints) + addFormattedEntry(entry, false) + + for (entry in gppBreakdown.percentBonuses) + addFormattedEntry(entry, true) + + val game = cityScreen.game + val ruleset = game.gameInfo!!.ruleset + add(MarkupRenderer.render(lines) { + game.pushScreen(CivilopediaScreen(ruleset, link = it)) + }) + + addCloseButton() + open(true) + } + + private fun GreatPersonPointsBreakdown.Entry.toString(isPercentage: Boolean) = + "{$source}: " + + when { + isAllGP -> (counter.values.firstOrNull() ?: 0).toStringSigned() + (if (isPercentage) "%" else "") + isPercentage -> counter.entries.joinToString { it.value.toStringSigned() + "% {${it.key}}" } + else -> counter.entries.joinToString { it.value.toStringSigned() + " {${it.key}}" } + } + + private fun GreatPersonPointsBreakdown.Entry.toString(isPercentage: Boolean, amount: Int) = + "{$source}: " + + amount.toStringSigned() + + (if (isPercentage) "%" else "") +} diff --git a/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt b/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt index 3dcbd2fdd3..9eb6111058 100644 --- a/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt +++ b/core/src/com/unciv/ui/screens/civilopediascreen/FormattedLine.kt @@ -2,6 +2,7 @@ package com.unciv.ui.screens.civilopediascreen import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.Colors import com.badlogic.gdx.graphics.Pixmap import com.badlogic.gdx.graphics.g2d.TextureRegion import com.badlogic.gdx.scenes.scene2d.Actor @@ -16,8 +17,8 @@ import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.unique.Unique import com.unciv.ui.components.extensions.getReadonlyPixmap -import com.unciv.ui.components.widgets.ColorMarkupLabel import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.widgets.ColorMarkupLabel import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Log @@ -65,7 +66,7 @@ class FormattedLine ( val indent: Int = 0, /** Defines vertical padding between rows, defaults to 5f. */ val padding: Float = Float.NaN, - /** Sets text color, accepts 6/3-digit web colors (e.g. #FFA040). */ + /** Sets text color, accepts 6/3-digit web colors (e.g. #FFA040) or names as defined by Gdx [Colors]. */ val color: String = "", /** Renders a separator line instead of text. Can be combined only with [color] and [size] (line width, default 2) */ val separator: Boolean = false, @@ -226,7 +227,7 @@ class FormattedLine ( val hex6 = String(charArrayOf(color[1], color[1], color[2], color[2], color[3], color[3])) return Color.valueOf(hex6) } - return defaultColor + return Colors.get(color.uppercase()) ?: defaultColor } /** Used only as parameter to [FormattedLine.render] and [MarkupRenderer.render] */