diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt b/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt index 846c0687f8..d120de88db 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt @@ -21,6 +21,8 @@ import com.unciv.models.stats.Stat import com.unciv.models.translations.equalsPlaceholderText import com.unciv.models.translations.getPlaceholderParameters import com.unciv.ui.components.extensions.randomWeighted +import com.unciv.utils.Log +import com.unciv.utils.Tag import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -210,6 +212,9 @@ class MapRegions (val ruleset: Ruleset){ return Pair(splitOffRegion, regionToSplit) } + /** Buckets for startBias to region assignments, used only in [assignRegions]. [PositiveFallback] is only for logging. */ + private enum class BiasTypes { Coastal, Positive, Negative, Random, PositiveFallback } + fun assignRegions(tileMap: TileMap, civilizations: List, gameParameters: GameParameters) { if (civilizations.isEmpty()) return @@ -256,28 +261,43 @@ class MapRegions (val ruleset: Ruleset){ normalizeStart(tileMap[region.startPosition!!], tileMap, minorCiv = false) } - val coastBiasCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.contains("Coast") } - val negativeBiasCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.any { bias -> bias.equalsPlaceholderText("Avoid []") } } - .sortedByDescending { ruleset.nations[it.civName]!!.startBias.size } // Civs with more complex avoids go first - val randomCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.isEmpty() }.toMutableList() // We might fill this up as we go - // The rest are positive bias - val positiveBiasCivs = civilizations.filterNot { it in coastBiasCivs || it in negativeBiasCivs || it in randomCivs } - .sortedBy { ruleset.nations[it.civName]!!.startBias.size } // civs with only one desired region go first - val positiveBiasFallbackCivs = ArrayList() // Civs who couldn't get their desired region at first pass + val civBiases = civilizations.associateWith { ruleset.nations[it.civName]!!.startBias } + // This ensures each civ can only be in one of the buckets + val civsByBiasType = civBiases.entries.groupBy( + keySelector = { + (_, startBias) -> + when { + gameParameters.noStartBias -> BiasTypes.Random + startBias.any { bias -> bias.equalsPlaceholderText("Avoid []") } -> BiasTypes.Negative + "Coast" in startBias -> BiasTypes.Coastal + startBias.isNotEmpty() -> BiasTypes.Positive + else -> BiasTypes.Random + } + }, + valueTransform = { (civ, _) -> civ } + ) + + val coastBiasCivs = civsByBiasType[BiasTypes.Coastal] + ?: emptyList() + val positiveBiasCivs = civsByBiasType[BiasTypes.Positive] + ?.sortedBy { civBiases[it]?.size } // civs with only one desired region go first + ?: emptyList() + val negativeBiasCivs = civsByBiasType[BiasTypes.Negative] + ?.sortedByDescending { civBiases[it]?.size } // Civs with more complex avoids go first + ?: emptyList() + val randomCivs = civsByBiasType[BiasTypes.Random] + ?.toMutableList() // We might fill this up as we go + ?: mutableListOf() + val positiveBiasFallbackCivs = mutableListOf() // Civs who couldn't get their desired region at first pass val unpickedRegions = regions.toMutableList() // First assign coast bias civs for (civ in coastBiasCivs) { - // If noStartBias is enabled consider these to be randomCivs - if (gameParameters.noStartBias) { - randomCivs.addAll(coastBiasCivs) - break - } - // Try to find a coastal start, preferably a really coastal one var startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].isCoastalTile() } .maxByOrNull { it.terrainCounts["Coastal"] ?: 0 } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Coastal, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue @@ -286,6 +306,7 @@ class MapRegions (val ruleset: Ruleset){ startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.getBaseTerrain().hasUnique(UniqueType.FreshWater) } } .maxByOrNull { it.terrainCounts["Coastal"] ?: 0 } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Coastal, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue @@ -294,6 +315,7 @@ class MapRegions (val ruleset: Ruleset){ startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].isAdjacentToRiver() } .maxByOrNull { it.terrainCounts["Coastal"] ?: 0 } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Coastal, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue @@ -302,72 +324,85 @@ class MapRegions (val ruleset: Ruleset){ startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.isAdjacentToRiver() } } .maxByOrNull { it.terrainCounts["Coastal"] ?: 0 } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Coastal, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue } // Else pick a random region at the end + logAssignRegion(false, BiasTypes.Coastal, civ) randomCivs.add(civ) } // Next do positive bias civs for (civ in positiveBiasCivs) { - // If noStartBias is enabled consider these to be randomCivs - if (gameParameters.noStartBias) { - randomCivs.addAll(positiveBiasCivs) - break - } - // Try to find a start that matches any of the desired regions, ideally with lots of desired terrain - val preferred = ruleset.nations[civ.civName]!!.startBias + val preferred = civBiases[civ]!! val startRegion = unpickedRegions.filter { it.type in preferred } .maxByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in preferred }.values.sum() } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Positive, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue - } else if (ruleset.nations[civ.civName]!!.startBias.size == 1) { // Civs with a single bias (only) get to look for a fallback region + } else if (preferred.size == 1) { // Civs with a single bias (only) get to look for a fallback region positiveBiasFallbackCivs.add(civ) } else { // Others get random starts + logAssignRegion(false, BiasTypes.Positive, civ) randomCivs.add(civ) } } // Do a second pass for fallback civs, choosing the region most similar to the desired type for (civ in positiveBiasFallbackCivs) { - val startRegion = getFallbackRegion(ruleset.nations[civ.civName]!!.startBias.first(), unpickedRegions) + val startRegion = getFallbackRegion(civBiases[civ]!!.first(), unpickedRegions) + logAssignRegion(true, BiasTypes.PositiveFallback, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) } // Next do negative bias ones (ie "Avoid []") for (civ in negativeBiasCivs) { - // If noStartBias is enabled consider these to be randomCivs - if (gameParameters.noStartBias) { - randomCivs.addAll(negativeBiasCivs) - break - } - - val avoided = ruleset.nations[civ.civName]!!.startBias.map { it.getPlaceholderParameters()[0] } - // Try to find a region not of the avoided types, secondary sort by least number of undesired terrains + val (avoidBias, preferred) = civBiases[civ]!! + .partition { bias -> bias.equalsPlaceholderText("Avoid []") } + val avoided = avoidBias.map { it.getPlaceholderParameters()[0] } + // Try to find a region not of the avoided types, secondary sort by + // least number of undesired terrains (weighed double) / most number of desired terrains val startRegion = unpickedRegions.filterNot { it.type in avoided } - .minByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in avoided }.values.sum() } + .minByOrNull { + 2 * it.terrainCounts.filterKeys { terrain -> terrain in avoided }.values.sum() + - it.terrainCounts.filterKeys { terrain -> terrain in preferred }.values.sum() + } if (startRegion != null) { + logAssignRegion(true, BiasTypes.Negative, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) continue - } else + } else { + logAssignRegion(false, BiasTypes.Negative, civ) randomCivs.add(civ) // else pick a random region at the end + } } // Finally assign the remaining civs randomly for (civ in randomCivs) { + // throws if regions.size < civilizations.size or if the assigning mismatched - leads to popup on newgame screen val startRegion = unpickedRegions.random() + logAssignRegion(true, BiasTypes.Random, civ, startRegion) assignCivToRegion(civ, startRegion) unpickedRegions.remove(startRegion) } } + private fun logAssignRegion(success: Boolean, startBiasType: BiasTypes, civ: Civilization, region: Region? = null) { + if (Log.backend.isRelease()) return + + val logCiv = { civ.civName + " " + ruleset.nations[civ.civName]!!.startBias.joinToString(",", "(", ")") } + val msg = if (success) "(%s): %s to %s" + else "no region (%s) found for %s" + Log.debug(Tag("assignRegions"), msg, startBiasType, logCiv, region) + } + private fun getRegionPriority(terrain: Terrain?): Int? { if (terrain == null) // ie "hybrid" return 99999 // a big number @@ -381,9 +416,9 @@ class MapRegions (val ruleset: Ruleset){ terrain.getMatchingUniques(UniqueType.RegionRequirePercentTwoTypes).first().params[3].toInt() } - private fun assignCivToRegion(civInfo: Civilization, region: Region) { + private fun assignCivToRegion(civ: Civilization, region: Region) { val tile = region.tileMap[region.startPosition!!] - region.tileMap.addStartingLocation(civInfo.civName, tile) + region.tileMap.addStartingLocation(civ.civName, tile) // Place impacts to keep city states etc at appropriate distance placeImpact(ImpactType.MinorCiv,tile, 6) @@ -1743,4 +1778,6 @@ class Region (val tileMap: TileMap, val rect: Rectangle, val continentID: Int = /** Returns number terrains with [name] */ fun getTerrainAmount(name: String) = terrainCounts[name] ?: 0 + + override fun toString() = "Region($type, ${tiles.size} tiles, ${terrainCounts.entries.joinToString { "${it.value} ${it.key}" }})" } diff --git a/core/src/com/unciv/utils/Log.kt b/core/src/com/unciv/utils/Log.kt index 54d9790284..ea584a98bd 100644 --- a/core/src/com/unciv/utils/Log.kt +++ b/core/src/com/unciv/utils/Log.kt @@ -23,7 +23,7 @@ object Log { */ val disableLogsFrom = ( System.getProperty("noLog") - ?: "Battle,Music,Sounds,Translations,WorkerAutomation" + ?: "Battle,Music,Sounds,Translations,WorkerAutomation,assignRegions" ).split(',').filterNot { it.isEmpty() }.toMutableSet() /** diff --git a/docs/Other/Civilization-related-JSON-files.md b/docs/Other/Civilization-related-JSON-files.md index 7aabd15eef..45952a0c6c 100644 --- a/docs/Other/Civilization-related-JSON-files.md +++ b/docs/Other/Civilization-related-JSON-files.md @@ -63,31 +63,38 @@ Each building can have the following attributes: This file contains all the nations and city states, including Barbarians and Spectator. -| Attribute | Type | Optional | Notes | -| --------- | ---- | -------- | ----- | -| name | String | Required | | -| leaderName | String | Default empty | Omit only for city states! If you want LeaderPortraits, the image file names must match exactly, including case. | -| style | String | Default empty | Modifier appended to pixel unit image names | -| adjective | String | Default empty | Currently unused | -| cityStateType | Enum | Default absent | Distinguishes Major Civilizations from City States (Cultured, Maritime, Mercantile, Militaristic) | -| startBias | List | Default empty | Zero or more of: terrainFilter or "Avoid [terrainFilter]". Two or more will be logically "and"-ed, and if the filters result in no choices, the entire attribute is ignored (e.g. `"startBias": ["Snow","Tundra"]` will _never_ work). | -| preferredVictoryType | Enum | Default Neutral | Neutral, Cultural, Diplomatic, Domination or Scientific | -| startIntroPart1 | String | Default empty | Introductory blurb shown to Player on game start... | -| startIntroPart2 | String | Default empty | ... second paragraph. ***NO*** "TBD"!!! Leave empty to skip that alert. | -| declaringWar | String | Default empty | another greeting | -| attacked | String | Default empty | another greeting | -| defeated | String | Default empty | another greeting | -| introduction | String | Default empty | another greeting | -| neutralHello | String | Default empty | another greeting | -| hateHello | String | Default empty | another greeting | -| tradeRequest | String | Default empty | another greeting | -| innerColor | 3x Integer | Default black | R, G, B for outer ring of nation icon | -| outerColor | 3x Integer | Required | R, G, B for inner circle of nation icon | -| uniqueName | String | Default empty | Decorative name for the special characteristic of this Nation | -| uniqueText | String | Default empty | Replacement text for "uniques". If empty, uniques are listed individually. | -| uniques | List | Default empty | Properties of the civilization - see [here](../Modders/Unique-parameters.md#general-uniques) | -| cities | List | Default empty | City names used sequentially for newly founded cities. | -| civilopediaText | List | Default empty | see [civilopediaText chapter](Miscellaneous-JSON-files.md#civilopedia-text) | +| Attribute | Type | Optional | Notes | +|----------------------|------------|------------------|------------------------------------------------------------------------------------------------------------------| +| name | String | Required | | +| leaderName | String | Default empty | Omit only for city states! If you want LeaderPortraits, the image file names must match exactly, including case. | +| style | String | Default empty | Modifier appended to pixel unit image names | +| adjective | String | Default empty | Currently unused | +| cityStateType | Enum | Default absent | Distinguishes Major Civilizations from City States (Cultured, Maritime, Mercantile, Militaristic) | +| startBias | List | Default empty | Zero or more of: terrainFilter or "Avoid [terrainFilter]". [^S] | +| preferredVictoryType | Enum | Default Neutral | Neutral, Cultural, Diplomatic, Domination or Scientific | +| startIntroPart1 | String | Default empty | Introductory blurb shown to Player on game start... | +| startIntroPart2 | String | Default empty | ... second paragraph. ***NO*** "TBD"!!! Leave empty to skip that alert. | +| declaringWar | String | Default empty | another greeting | +| attacked | String | Default empty | another greeting | +| defeated | String | Default empty | another greeting | +| introduction | String | Default empty | another greeting | +| neutralHello | String | Default empty | another greeting | +| hateHello | String | Default empty | another greeting | +| tradeRequest | String | Default empty | another greeting | +| innerColor | 3x Integer | Default black | R, G, B for outer ring of nation icon | +| outerColor | 3x Integer | Required | R, G, B for inner circle of nation icon | +| uniqueName | String | Default empty | Decorative name for the special characteristic of this Nation | +| uniqueText | String | Default empty | Replacement text for "uniques". If empty, uniques are listed individually. | +| uniques | List | Default empty | Properties of the civilization - see [here](../Modders/Unique-parameters.md#general-uniques) | +| cities | List | Default empty | City names used sequentially for newly founded cities. | +| civilopediaText | List | Default empty | see [civilopediaText chapter](Miscellaneous-JSON-files.md#civilopedia-text) | + +[^S]: A "Coast" preference (_unless_ combined with "Avoid") is translated to a complex test for coastal land tiles, tiles next to Lakes, river tiles or near-river tiles, and such civs are processed first. Other startBias entries are ignored in that case. + Other positive (no "Avoid") startBias are processed next. Multiple positive preferences are treated equally, but get no "fallback". + Single positive startBias can get a "fallback" region if there is no (or no more) region with that primary type: any leftover region with as much of the specified terrain as possible will do. + Multiple "Avoid" entries are treated equally (and reduce chance for success - if no region is left avoiding _all_ specified types that civ gets a random one). + When combining preferred terrain with "Avoid", the latter takes precedence, and preferred terrain only has minor weight when choosing between regions that are not of a type to avoid. + These notes are **only** valid when playing on generated maps, loaded maps from map editor get no "regions" and startBias is processed differently (but you can expect single-entry startBias to work best). ## Policies.json