Streamline and thereby reduce size of the save game json (#11728)

This commit is contained in:
SomeTroglodyte 2024-06-14 16:39:59 +02:00 committed by GitHub
parent e74897469c
commit 3c91647fb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 112 additions and 101 deletions

View File

@ -1,25 +0,0 @@
package com.unciv.json
import com.badlogic.gdx.math.Vector2
/**
* @see NonStringKeyMapSerializer
*/
class HashMapVector2<T> : HashMap<Vector2, T>() {
companion object {
fun createSerializer(): NonStringKeyMapSerializer<MutableMap<Vector2, Any>, Vector2> {
@Suppress("UNCHECKED_CAST") // kotlin can't tell that HashMapVector2 is also a MutableMap within generics
val mapClass = HashMapVector2::class.java as Class<MutableMap<Vector2, Any>>
return NonStringKeyMapSerializer(
mapClass,
Vector2::class.java
) { HashMapVector2() }
}
fun getSerializerClass(): Class<MutableMap<Vector2, Any>> {
@Suppress("UNCHECKED_CAST") // kotlin can't tell that HashMapVector2 is also a MutableMap within generics
return HashMapVector2::class.java as Class<MutableMap<Vector2, Any>>
}
}
}

View File

@ -0,0 +1,63 @@
package com.unciv.json
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.JsonValue
import com.unciv.ui.components.extensions.toPrettyString
/**
* Dedicated HashMap with [Vector2] keys for [Civilization.lastSeenImprovement].
*
* Deals with the problem that serialization uses map keys converted to strings as json object field names,
* and generic deserialization can't convert them back,
* by implementing Json.Serializable and parsing the key string explicitly.
*
* Backward compatibility is implemented in [readOldFormat], but there can be no forward compatibility.
* To remove compatibility, remove the `open` modifier, remove `class HashMapVector2`, remove `readOldFormat`, and the first two lines of `read`. Moving to another package is now allowed.
* To understand the old solution with its nonstandard format that readOldFormat parses, use git history
* to dig out the com.unciv.json.HashMapVector2 and com.unciv.json.NonStringKeyMapSerializer files.
*/
open class LastSeenImprovement(
private val map: HashMap<Vector2, String> = hashMapOf()
) : MutableMap<Vector2, String> by map, Json.Serializable {
override fun write(json: Json) {
for ((key, value) in entries) {
val name = key.toPrettyString()
json.writeValue(name, value, String::class.java)
}
}
override fun read(json: Json, jsonData: JsonValue) {
if (jsonData.get("class")?.asString() == "com.unciv.json.HashMapVector2")
return readOldFormat(json, jsonData)
for (entry in jsonData) {
val key = entry.name.toVector2()
val value = if (entry.isValue) entry.asString() else entry.getString("value")
put(key, value)
}
}
private fun String.toVector2(): Vector2 {
val (x, y) = removeSurrounding("(", ")").split(',')
return Vector2(x.toFloat(), y.toFloat())
}
private fun readOldFormat(json: Json, jsonData: JsonValue) {
for (entry in jsonData.get("entries")) {
val key = json.readValue(Vector2::class.java, entry[0])
val value = json.readValue(String::class.java, entry[1])
put(key, value)
}
}
override fun equals(other: Any?) = when (other) {
is LastSeenImprovement -> map == other.map
is Map<*, *> -> map == other
else -> false
}
override fun hashCode() = map.hashCode()
}
/** Compatibility kludge required for backward compatibility. Without this, Gdx won't even run our overridden `read` above. */
private class HashMapVector2 : LastSeenImprovement()

View File

@ -1,54 +0,0 @@
package com.unciv.json
import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.Json.Serializer
import com.badlogic.gdx.utils.JsonValue
import com.unciv.logic.automation.civilization.Encampment
/**
* A [Serializer] for gdx's [Json] that serializes a map that does not have [String] as its key class.
*
* Exists solely because [Json] always serializes any map by converting its key to [String], so when you load it again,
* all your keys are [String], messing up value retrieval.
*
* To work around that, we have to use a custom serializer. A custom serializer in Json is only added for a specific class
* and only checks for direct equality, and since we can't just do `HashMap<Any, *>::class.java`, only `HashMap::class.java`,
* we have to create a completely new class and use that class as [mapClass] here.
*
* @param MT Must be a type that extends [MutableMap]
* @param KT Must be the key type of [MT]
*/
class NonStringKeyMapSerializer<MT: MutableMap<KT, Any>, KT>(
private val mapClass: Class<MT>,
private val keyClass: Class<KT>,
private val mutableMapFactory: () -> MT
) : Serializer<MT> {
override fun write(json: Json, toWrite: MT, knownType: Class<*>) {
json.writeObjectStart()
json.writeType(mapClass)
json.writeArrayStart("entries")
for ((key, value) in toWrite) {
json.writeArrayStart()
json.writeValue(key)
json.writeValue(value, null)
json.writeArrayEnd()
}
json.writeArrayEnd()
json.writeObjectEnd()
}
override fun read(json: Json, jsonData: JsonValue, type: Class<*>?): MT {
val result = mutableMapFactory()
var entry = jsonData.get("entries").child
while (entry != null) {
val key = json.readValue(keyClass, entry.child)
val value = json.readValue<Any>(null, entry.child.next)
result[key!!] = value!!
entry = entry.next
}
return result
}
}

View File

@ -5,11 +5,7 @@ import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.utils.Json import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.JsonWriter import com.badlogic.gdx.utils.JsonWriter
import com.badlogic.gdx.utils.SerializationException import com.badlogic.gdx.utils.SerializationException
import com.unciv.logic.civilization.CivRankingHistory
import com.unciv.logic.civilization.Notification
import com.unciv.logic.map.tile.TileHistory
import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyboardBindings
import java.time.Duration import java.time.Duration
@ -24,7 +20,6 @@ fun json() = Json(JsonWriter.OutputType.json).apply {
setIgnoreDeprecated(true) setIgnoreDeprecated(true)
ignoreUnknownFields = true ignoreUnknownFields = true
setSerializer(HashMapVector2.getSerializerClass(), HashMapVector2.createSerializer())
setSerializer(Duration::class.java, DurationSerializer()) setSerializer(Duration::class.java, DurationSerializer())
setSerializer(KeyCharAndCode::class.java, KeyCharAndCode.Serializer()) setSerializer(KeyCharAndCode::class.java, KeyCharAndCode.Serializer())
} }

View File

@ -92,7 +92,7 @@ class GameInfo : IsPartOfGameInfoSerialization, HasGameInfoSerializationVersion
companion object { companion object {
/** The current compatibility version of [GameInfo]. This number is incremented whenever changes are made to the save file structure that guarantee that /** The current compatibility version of [GameInfo]. This number is incremented whenever changes are made to the save file structure that guarantee that
* previous versions of the game will not be able to load or play a game normally. */ * previous versions of the game will not be able to load or play a game normally. */
const val CURRENT_COMPATIBILITY_NUMBER = 3 const val CURRENT_COMPATIBILITY_NUMBER = 4
val CURRENT_COMPATIBILITY_VERSION = CompatibilityVersion(CURRENT_COMPATIBILITY_NUMBER, UncivGame.VERSION) val CURRENT_COMPATIBILITY_VERSION = CompatibilityVersion(CURRENT_COMPATIBILITY_NUMBER, UncivGame.VERSION)

View File

@ -3,7 +3,7 @@ package com.unciv.logic.civilization
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.json.HashMapVector2 import com.unciv.json.LastSeenImprovement
import com.unciv.logic.GameInfo import com.unciv.logic.GameInfo
import com.unciv.logic.IsPartOfGameInfoSerialization import com.unciv.logic.IsPartOfGameInfoSerialization
import com.unciv.logic.MultiFilter import com.unciv.logic.MultiFilter
@ -209,7 +209,7 @@ class Civilization : IsPartOfGameInfoSerialization {
fun hasExplored(tile: Tile) = tile.isExplored(this) fun hasExplored(tile: Tile) = tile.isExplored(this)
private val lastSeenImprovement = HashMapVector2<String>() private val lastSeenImprovement = LastSeenImprovement()
// To correctly determine "game over" condition as clarified in #4707 // To correctly determine "game over" condition as clarified in #4707
var hasEverOwnedOriginalCapital: Boolean = false var hasEverOwnedOriginalCapital: Boolean = false

View File

@ -1,14 +1,24 @@
package com.unciv.models package com.unciv.models
import com.badlogic.gdx.utils.Json
import com.badlogic.gdx.utils.JsonValue
import com.unciv.logic.IsPartOfGameInfoSerialization import com.unciv.logic.IsPartOfGameInfoSerialization
/**
* Implements a specialized Map storing on-zero Integers.
* - All mutating methods will remove keys when their value is zeroed
* - [get] on a nonexistent key returns 0
* - The Json.Serializable implementation ensures compact format, it does not solve the non-string-key map problem.
* - Therefore, Deserialization works properly ***only*** with [K] === String.
* (ignoring this will return a deserialized map, but the keys will violate the compile-time type and BE strings)
*/
open class Counter<K>( open class Counter<K>(
fromMap: Map<K, Int>? = null fromMap: Map<K, Int>? = null
) : LinkedHashMap<K, Int>(fromMap?.size ?: 10), IsPartOfGameInfoSerialization { ) : LinkedHashMap<K, Int>(fromMap?.size ?: 10), IsPartOfGameInfoSerialization, Json.Serializable {
init { init {
if (fromMap != null) if (fromMap != null)
for ((key, value) in fromMap) for ((key, value) in fromMap)
put(key, value) super.put(key, value)
} }
override operator fun get(key: K): Int { // don't return null if empty override operator fun get(key: K): Int { // don't return null if empty
@ -47,11 +57,7 @@ open class Counter<K>(
fun sumValues() = values.sum() fun sumValues() = values.sum()
override fun clone(): Counter<K> { override fun clone() = Counter(this)
val newCounter = Counter<K>()
newCounter.add(this)
return newCounter
}
companion object { companion object {
val ZERO: Counter<String> = object : Counter<String>() { val ZERO: Counter<String> = object : Counter<String>() {
@ -59,5 +65,23 @@ open class Counter<K>(
throw UnsupportedOperationException("Do not modify Counter.ZERO") throw UnsupportedOperationException("Do not modify Counter.ZERO")
} }
} }
}
override fun write(json: Json) {
for ((key, value) in entries) {
val name = if (key is String) key else key.toString()
json.writeValue(name, value, Int::class.java)
}
}
override fun read(json: Json, jsonData: JsonValue) {
for (entry in jsonData) {
@Suppress("UNCHECKED_CAST")
// Default Gdx does the same. If K is NOT String, then Gdx would still store String keys. And we can't reify K to check..
val key = entry.name as K
val value = if (entry.isValue) entry.asInt() else entry.getInt("value")
put(key, value)
}
} }
} }

View File

@ -1,14 +1,14 @@
package com.unciv.logic package com.unciv.logic
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
import com.unciv.json.HashMapVector2
import com.unciv.logic.civilization.CivRankingHistory import com.unciv.logic.civilization.CivRankingHistory
import com.unciv.logic.civilization.CivilopediaAction import com.unciv.logic.civilization.CivilopediaAction
import com.unciv.logic.civilization.DiplomacyAction import com.unciv.logic.civilization.DiplomacyAction
import com.unciv.json.LastSeenImprovement
import com.unciv.logic.civilization.LocationAction import com.unciv.logic.civilization.LocationAction
import com.unciv.logic.civilization.Notification import com.unciv.logic.civilization.Notification
import com.unciv.logic.map.tile.TileHistory import com.unciv.logic.map.tile.TileHistory
import com.unciv.models.Counter
import com.unciv.testing.GdxTestRunner import com.unciv.testing.GdxTestRunner
import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyboardBinding import com.unciv.ui.components.input.KeyboardBinding
@ -38,11 +38,12 @@ class SerializationTests {
} }
@Test @Test
fun `test HashMapVector2 serialization roundtrip`() { //@RedirectOutput(RedirectPolicy.Show)
val data = HashMapVector2<Color>() fun `test LastSeenImprovement serialization roundtrip`() {
data[Vector2.Zero] = Color.GRAY val data = LastSeenImprovement()
data[Vector2.X] = Color.CORAL data[Vector2.Zero] = "Borehole"
data[Vector2.Y] = Color.CHARTREUSE data[Vector2.X] = "Smokestack"
data[Vector2.Y] = "Waffle stand"
testRoundtrip(data) testRoundtrip(data)
} }
@ -111,6 +112,13 @@ class SerializationTests {
} }
} }
/** Note that no other Counter<X> will pass this test */
@Test
fun `test Counter(String) serialization roundtrip`() {
val data = Counter(mapOf("Foo" to 1, "Bar" to 3, "Towel" to 42))
testRoundtrip(data)
}
///////////////////////////////// Helper ///////////////////////////////// Helper
private inline fun <reified T> testRoundtrip( private inline fun <reified T> testRoundtrip(
data: T, data: T,