Under-the-hood improvements around Speed and Years (#13482)

* Mini-refactor: YearsPerTurn can be immutable, support destructuring

* Lint: Replace non-rendering unicode points with escapes

* Wiki: Describe `Speed.turns` better

* A unit test for turn-to-year conversion

* Faster turn-to-year math

* Readability changes
This commit is contained in:
SomeTroglodyte 2025-06-26 22:44:54 +02:00 committed by GitHub
parent 4ac0400c2e
commit fce04aaddd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 95 additions and 32 deletions

View File

@ -304,21 +304,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
return turns + (totalTurns * startPercent / 100)
}
fun getYear(turnOffset: Int = 0): Int {
val turn = getEquivalentTurn() + turnOffset
val yearsToTurn = speed.yearsPerTurn
var year = speed.startYear
var i = 0
var yearsPerTurn: Float
while (i < turn) {
yearsPerTurn = (yearsToTurn.firstOrNull { i < it.untilTurn }?.yearInterval ?: yearsToTurn.last().yearInterval)
year += yearsPerTurn
++i
}
return year.toInt()
}
fun getYear(turnOffset: Int = 0) = speed.turnToYear(getEquivalentTurn() + turnOffset).toInt()
fun calculateChecksum(): String {
val oldChecksum = checksum

View File

@ -29,11 +29,39 @@ class Speed : RulesetObject(), IsPartOfGameInfoSerialization {
var startYear: Float = -4000f
var turns: ArrayList<HashMap<String, Float>> = ArrayList()
data class YearsPerTurn(val yearInterval: Float, val untilTurn: Int)
val yearsPerTurn: ArrayList<YearsPerTurn> by lazy {
ArrayList<YearsPerTurn>().apply {
turns.forEach { this.add(YearsPerTurn(it["yearsPerTurn"]!!, it["untilTurn"]!!.toInt())) }
// These could be private but for RulesetValidator checking it
data class YearsPerTurn(val yearInterval: Float, val untilTurn: Int) {
internal constructor(rawRow: HashMap<String, Float>) : this(rawRow["yearsPerTurn"]!!, rawRow["untilTurn"]!!.toInt())
}
val yearsPerTurn: ArrayList<YearsPerTurn> by lazy { turns.mapTo(ArrayList()) { YearsPerTurn(it) } }
/** End of defined turn range, used for starting Era's `startPercent` calculation */
fun numTotalTurns(): Int = yearsPerTurn.last().untilTurn
/** Calculate a Year from a turn number.
*
* Note that years can have fractional parts and the integer part of the year for two consecutive turns _can_ be equal,
* but Unciv currently has no way to display that. This is left as Float to enable such display in the future,
* maybe as months, or even the 18+1 'months' of the mayan Haab'.
*
* @param turn The logical turn number, any offset from starting in an advanced Era already added in
*/
fun turnToYear(turn: Int): Float {
var year = startYear
var intervalStartTurn = 0
val lastIntervalEndTurn = numTotalTurns()
for ((turnLength, intervalEndTurn) in yearsPerTurn) {
if (intervalStartTurn >= turn) break // ensure year isn't projected backwards for negative `turn`
if (turn <= intervalEndTurn || intervalEndTurn == lastIntervalEndTurn) {
// We can interpolate linearly within this interval and are done
year += (turn - intervalStartTurn) * turnLength
break
}
// Accumulate total length in years of this interval and move on to the following intervals.
year += (intervalEndTurn - intervalStartTurn) * turnLength
intervalStartTurn = intervalEndTurn
}
return year
}
val statCostModifiers: EnumMap<Stat, Float> by lazy {
@ -81,6 +109,4 @@ class Speed : RulesetObject(), IsPartOfGameInfoSerialization {
yield(FormattedLine("Start year: [" + ("{[${abs(startYear).toInt()}] " + (if (startYear < 0) "BC" else "AD") + "}]").tr()))
}.toList()
override fun getSortGroup(ruleset: Ruleset): Int = (modifier * 1000).toInt()
fun numTotalTurns(): Int = yearsPerTurn.last().untilTurn
}

View File

@ -84,7 +84,7 @@ internal class WorldScreenTopBarResources(topbar: WorldScreenTopBar) : ScalingTa
val yearText = YearTextUtil.toYearText(
civInfo.gameInfo.getYear(), civInfo.isLongCountDisplay()
)
turnsLabel.setText(Fonts.turn + "" + civInfo.gameInfo.turns.tr() + "|" + yearText)
turnsLabel.setText(Fonts.turn + "\u2004" + civInfo.gameInfo.turns.tr() + "\u2004|\u2004" + yearText) // U+2004: Three-Per-Em Space
resourcesWrapper.clearChildren()
val civResources = civInfo.getCivResourcesByName()

View File

@ -60,7 +60,7 @@ internal class WorldScreenTopBarStats(topbar: WorldScreenTopBar) : ScalingTableW
isTransform = false
defaults().pad(defaultTopPad, defaultHorizontalPad, defaultBottomPad, defaultHorizontalPad)
fun addStat(
icon: String,
label: Label,
@ -124,7 +124,7 @@ internal class WorldScreenTopBarStats(topbar: WorldScreenTopBar) : ScalingTableW
resetScale()
val nextTurnStats = civInfo.stats.statsForNextTurn
goldLabel.setText(civInfo.gold.tr())
goldPerTurnLabel.setText(rateLabel(nextTurnStats.gold))
@ -155,9 +155,9 @@ internal class WorldScreenTopBarStats(topbar: WorldScreenTopBar) : ScalingTableW
// kotlin Float division by Zero produces `Float.POSITIVE_INFINITY`, not an exception
val turnsToNextPolicy = (civInfo.policies.getCultureNeededForNextPolicy() - civInfo.policies.storedCulture) / nextTurnStats.culture
cultureString += when {
turnsToNextPolicy <= 0f -> "(!)" // Can choose policy right now
nextTurnStats.culture <= 0 -> "(${Fonts.infinity})" // when you start the game, you're not producing any culture
else -> "(" + Fonts.turn + "" + ceil(turnsToNextPolicy).toInt().tr() + ")"
turnsToNextPolicy <= 0f -> "\u2004(!)" // Can choose policy right now
nextTurnStats.culture <= 0 -> "\u2004(${Fonts.infinity})" // when you start the game, you're not producing any culture
else -> "\u2004(" + Fonts.turn + "\u2009" + ceil(turnsToNextPolicy).toInt().tr() + ")" // U+2004: Three-Per-Em Space, U+2009: Thin Space
}
return cultureString
}

View File

@ -105,12 +105,16 @@ Each speed can have the following attributes:
### Time interval per turn
The "turns" attribute defines the number of years passed between turns. The attribute consists of a list of hashmaps, each hashmaps in turn having 2 required attributes: "yearsPerTurn" (Float) and "untilTurn" (Integer)
The "turns" attribute defines the number of years passing between turns. The attribute consists of a list of objects, each having 2 required attributes: "yearsPerTurn" (Float) and "untilTurn" (Integer)
| Attribute | Type | Default | Notes |
|--------------|---------|----------|------------------------------------------------------------------------------------------|
| yearsPerTurn | Integer | Required | Number of years passed between turns |
| untilTurn | Integer | Required | Which turn that this "speed" is active until (if it is the last object, this is ignored) |
| Attribute | Type | Default | Notes |
|--------------|---------|----------|------------------------------------------------------------|
| yearsPerTurn | Integer | Required | Number of years passing between turns |
| untilTurn | Integer | Required | End of this interval (if it is the last object, see below) |
For each row, "yearsPerTurn" is applied up to the "untilTurn"-1 to "untilTurn" step.
The last "untilTurn" in the list is ignored for year calculation, that is, if a game passes that turn number, years continue to increment by the "yearsPerTurn" of the last entry.
However, this is used when starting a game in a later Era: Era.startPercent is relative to the last "untilTurn".
The code below is an example of a valid "turns" definition and it specifies that the first 50 turns of a game last for 60 years each, then the next 30 turns (and any played after the 80th) last for 40 years each.

View File

@ -4,6 +4,7 @@ package com.unciv.testing
import com.badlogic.gdx.Gdx
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.GameInfo
import com.unciv.models.metadata.BaseRuleset
import com.unciv.models.metadata.GameSettings
import com.unciv.models.ruleset.Ruleset
@ -328,4 +329,50 @@ class BasicTests {
}
return stats
}
@Test
fun turnToYearTest() {
// Pretty random choice, but ensures 'turn > last definition' and 'float to int rounding' is tested
val testData = mapOf(
"Quick" to mapOf(
0 to -4000,
100 to 800,
200 to 1860,
300 to 2020,
5000 to 6720
),
"Standard" to mapOf(
99 to -400,
479 to 2039,
999 to 2299
),
"Epic" to mapOf(
66 to -2350,
666 to 2008,
4242 to 3796
),
"Marathon" to mapOf(
222 to -1280,
1111 to 1978,
1400 to 2041
),
)
val gameInfo = GameInfo()
gameInfo.ruleset = ruleset
Assert.assertEquals(0, ruleset.eras[gameInfo.gameParameters.startingEra]!!.startPercent)
var fails = 0
for ((speedName, tests) in testData) {
val speed = ruleset.speeds[speedName]!!
gameInfo.speed = speed
for ((turn, expected) in tests) {
val actual = gameInfo.getYear(turn)
if (actual == expected) continue
println("speed: $speedName, turn: $turn, expected: $expected, actual: $actual")
fails++
}
}
Assert.assertEquals("Some turn to year conversions do not match", 0, fails)
}
}