Don't prematurely round Great person point boni and GPP breakdown UI (#10924)

* GreatPersonPointsBreakdown class and logic use

* Minor comment

* Fix untrue wiki claim that civilopediaText color accepts names

* GreatPersonPointsBreakdown UI available by clicking in the CityStatsTable GPP section

* Two separate assembly lines for two different products of the factory

* One more code-deduplication

* Fix rounding - rounds *up* on x.5

* Clearer display code (but output unchanged)

* Minor comment clarification
This commit is contained in:
SomeTroglodyte 2024-02-17 20:45:02 +01:00 committed by GitHub
parent 7dc398184f
commit 5b698102ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 239 additions and 84 deletions

View File

@ -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<String, Counter<String>> {
val sourceToGPP = HashMap<String, Counter<String>>()
val specialistsCounter = Counter<String>()
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<String>()
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<String> {
val gppCounter = Counter<String>()
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) {

View File

@ -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<String> = 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<String>()
val basePoints = ArrayList<Entry>()
val percentBonuses = ArrayList<Entry>()
/** 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<String> {
// Accumulate base points as fake "fixed-point"
val result = Counter<String>()
for (entry in basePoints)
result.add(entry.counter * fixedPointFactor)
// Accumulate percentage bonuses additively not multiplicatively
val bonuses = Counter<String>()
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
}
}

View File

@ -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]))

View File

@ -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)

View File

@ -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<FormattedLine>()
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 "")
}

View File

@ -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] */