diff --git a/core/src/com/unciv/logic/GameStarter.kt b/core/src/com/unciv/logic/GameStarter.kt index 3398604a3b..57b4932bde 100644 --- a/core/src/com/unciv/logic/GameStarter.kt +++ b/core/src/com/unciv/logic/GameStarter.kt @@ -3,21 +3,18 @@ package com.unciv.logic import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.civilization.* -import com.unciv.logic.map.BFS import com.unciv.logic.map.TileInfo import com.unciv.logic.map.TileMap import com.unciv.logic.map.mapgenerator.MapGenerator import com.unciv.models.metadata.GameParameters import com.unciv.models.metadata.GameSetupInfo -import com.unciv.models.ruleset.Era import com.unciv.models.ruleset.ModOptionsConstants import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.tile.ResourceType import java.util.* -import kotlin.collections.ArrayList import kotlin.collections.HashMap -import kotlin.math.max +import kotlin.collections.HashSet object GameStarter { // temporary instrumentation while tuning/debugging @@ -86,7 +83,7 @@ object GameStarter { if (tileMap.continentSizes.isEmpty()) // Probably saved map without continent data runAndMeasure("assignContinents") { - mapGen.assignContinents(tileMap) + tileMap.assignContinents() } runAndMeasure("addCivStartingUnits") { @@ -244,23 +241,21 @@ object GameStarter { for (tile in tileMap.values) { startScores[tile] = tile.getTileStartScore() } + val allCivs = gameInfo.civilizations.filter { !it.isBarbarian() } + val landTilesInBigEnoughGroup = getCandidateLand(allCivs.size, tileMap, startScores) // First we get start locations for the major civs, on the second pass the city states (without predetermined starts) can squeeze in wherever - // I hear copying code is good val civNamesWithStartingLocations = tileMap.startingLocationsByNation.keys - val bestCivs = gameInfo.civilizations.filter { !it.isBarbarian() && (!it.isCityState() || it.civName in civNamesWithStartingLocations) } - val bestLocations = getStartingLocations(bestCivs, tileMap, startScores) + val bestCivs = allCivs.filter { !it.isCityState() || it.civName in civNamesWithStartingLocations } + val bestLocations = getStartingLocations(bestCivs, tileMap, landTilesInBigEnoughGroup, startScores) for ((civ, tile) in bestLocations) { - if (civ.civName in civNamesWithStartingLocations) // Already have explicit starting locations - continue - + // A nation can have multiple marked starting locations, of which the first pass may have chosen one + tileMap.removeStartingLocations(civ.civName) // Mark the best start locations so we remember them for the second pass tileMap.addStartingLocation(civ.civName, tile) } - val startingLocations = getStartingLocations( - gameInfo.civilizations.filter { !it.isBarbarian() }, - tileMap, startScores) + val startingLocations = getStartingLocations(allCivs, tileMap, landTilesInBigEnoughGroup, startScores) val settlerLikeUnits = ruleSet.units.filter { it.value.uniqueObjects.any { unique -> unique.placeholderText == Constants.settlerUnique } @@ -349,70 +344,66 @@ object GameStarter { } } + private fun getCandidateLand( + civCount: Int, + tileMap: TileMap, + startScores: HashMap + ): Map { + if (tileMap.continentSizes.isEmpty()) tileMap.assignContinents() - private fun getStartingLocations(civs: List, tileMap: TileMap, startScores: HashMap): HashMap { - val landTilesInBigEnoughGroup = tileMap.landTilesInBigEnoughGroup - if (landTilesInBigEnoughGroup.isEmpty()) { - // Worst case - a pre-made map with continent data. This means we didn't re-run assignContinents, - // so we don't have a cached landTilesInBigEnoughGroup. So we need to do it the hard way. - var landTiles = tileMap.values - // Games starting on snow might as well start over... - .filter { it.isLand && !it.isImpassible() && it.baseTerrain != Constants.snow } - while (landTiles.any()) { - val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() } - bfs.stepToEnd() - val tilesInGroup = bfs.getReachedTiles() - landTiles = landTiles.filter { it !in tilesInGroup } - if (tilesInGroup.size > 20) // is this a good number? I dunno, but it's easy enough to change later on - landTilesInBigEnoughGroup.addAll(tilesInGroup) - } + // We want to distribute starting locations fairly, and thus not place anybody on a small island + // - unless necessary. Old code would only consider landmasses >= 20 tiles. + // Instead, take continents until >=75% total area or everybody can get their own island + val orderedContinents = tileMap.continentSizes.asSequence().sortedByDescending { it.value }.toList() + val totalArea = tileMap.continentSizes.values.sum() + var candidateArea = 0 + val candidateContinents = HashSet() + for ((index, continentSize) in orderedContinents.withIndex()) { + candidateArea += continentSize.value + candidateContinents.add(continentSize.key) + if (candidateArea * 4 >= totalArea * 3) break + if (index >= civCount) break } + return startScores.filter { it.key.getContinent() in candidateContinents } + } + + private fun getStartingLocations( + civs: List, + tileMap: TileMap, + landTilesInBigEnoughGroup: Map, + startScores: HashMap + ): HashMap { + val civsOrderedByAvailableLocations = civs.shuffled() // Order should be random since it determines who gets best start .sortedBy { civ -> when { civ.civName in tileMap.startingLocationsByNation -> 1 // harshest requirements - civ.nation.startBias.contains("Tundra") -> 2 // Tundra starts are hard to find, so let's do them first - civ.nation.startBias.isNotEmpty() -> 3 // less harsh - else -> 4 // no requirements + civ.nation.startBias.any { it in tileMap.naturalWonders } -> 2 + civ.nation.startBias.contains("Tundra") -> 3 // Tundra starts are hard to find, so let's do them first + civ.nation.startBias.isNotEmpty() -> 4 // less harsh + else -> 5 // no requirements } } - for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 4 downTo 0) { - val freeTiles = landTilesInBigEnoughGroup - .filter { - HexMath.getDistanceFromEdge(it.position, tileMap.mapParameters) >= - (minimumDistanceBetweenStartingLocations * 2) /3 - }.toMutableList() + for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 6 downTo 0) { + val freeTiles = landTilesInBigEnoughGroup.asSequence() + .filter { + HexMath.getDistanceFromEdge(it.key.position, tileMap.mapParameters) >= + (minimumDistanceBetweenStartingLocations * 2) / 3 + }.sortedBy { it.value } + .map { it.key } + .toMutableList() val startingLocations = HashMap() for (civ in civsOrderedByAvailableLocations) { - var startingLocation: TileInfo - val presetStartingLocation = tileMap.startingLocationsByNation[civ.civName]?.randomOrNull() // in case map editor is extended to allow alternate starting locations for a nation - var distanceToNext = minimumDistanceBetweenStartingLocations - - if (presetStartingLocation != null) startingLocation = presetStartingLocation + val distanceToNext = minimumDistanceBetweenStartingLocations / + (if (civ.isCityState()) 2 else 1) // We allow city states to squeeze in tighter + val presetStartingLocation = tileMap.startingLocationsByNation[civ.civName]?.randomOrNull() + val startingLocation = if (presetStartingLocation != null) presetStartingLocation else { if (freeTiles.isEmpty()) break // we failed to get all the starting tiles with this minimum distance - if (civ.isCityState()) - distanceToNext = minimumDistanceBetweenStartingLocations / 2 // We allow random city states to squeeze in tighter - - freeTiles.sortBy { startScores[it] } - - var preferredTiles = freeTiles.toList() - - for (startBias in civ.nation.startBias) { - preferredTiles = when { - startBias.startsWith("Avoid [") -> { - val tileToAvoid = startBias.removePrefix("Avoid [").removeSuffix("]") - preferredTiles.filter { !it.matchesTerrainFilter(tileToAvoid) } - } - startBias == Constants.coast -> preferredTiles.filter { it.isCoastalTile() } - else -> preferredTiles.filter { it.matchesTerrainFilter(startBias) } - } - } - - startingLocation = if (preferredTiles.isNotEmpty()) preferredTiles.last() else freeTiles.last() + getOneStartingLocation(civ, tileMap, freeTiles, startScores) } startingLocations[civ] = startingLocation freeTiles.removeAll(tileMap.getTilesInDistance(startingLocation.position, distanceToNext)) @@ -424,6 +415,36 @@ object GameStarter { throw Exception("Didn't manage to get starting tiles even with distance of 1?") } + private fun getOneStartingLocation( + civ: CivilizationInfo, + tileMap: TileMap, + freeTiles: MutableList, + startScores: HashMap + ): TileInfo { + if (civ.nation.startBias.any { it in tileMap.naturalWonders }) { + // startPref wants Natural wonder neighbor: Rare and very likely to be outside getDistanceFromEdge + val wonderNeighbor = tileMap.values.asSequence() + .filter { it.isNaturalWonder() && it.naturalWonder!! in civ.nation.startBias } + .sortedByDescending { startScores[it] } + .firstOrNull() + if (wonderNeighbor != null) return wonderNeighbor + } + + var preferredTiles = freeTiles.toList() + for (startBias in civ.nation.startBias) { + preferredTiles = when { + startBias.startsWith("Avoid [") -> { + val tileToAvoid = startBias.removePrefix("Avoid [").removeSuffix("]") + preferredTiles.filter { !it.matchesTerrainFilter(tileToAvoid) } + } + startBias == Constants.coast -> preferredTiles.filter { it.isCoastalTile() } + startBias in tileMap.naturalWonders -> preferredTiles // passthrough: already failed + else -> preferredTiles.filter { it.matchesTerrainFilter(startBias) } + } + } + return preferredTiles.lastOrNull() ?: freeTiles.last() + } + private fun addConsolationPrize(gameInfo: GameInfo, spawn: TileInfo, points: Int) { val relevantTiles = spawn.getTilesInDistanceRange(1..2).shuffled() var addedPoints = 0 diff --git a/core/src/com/unciv/logic/map/TileMap.kt b/core/src/com/unciv/logic/map/TileMap.kt index 0a06926a8e..d6db27141f 100644 --- a/core/src/com/unciv/logic/map/TileMap.kt +++ b/core/src/com/unciv/logic/map/TileMap.kt @@ -2,7 +2,6 @@ package com.unciv.logic.map import com.badlogic.gdx.math.Vector2 import com.unciv.Constants -import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.HexMath import com.unciv.logic.civilization.CivilizationInfo @@ -79,9 +78,6 @@ class TileMap { @Transient val startingLocationsByNation = HashMap>() - @Transient - val landTilesInBigEnoughGroup = ArrayList() // cached at map gen - //endregion //region Constructors @@ -546,11 +542,42 @@ class TileMap { // we do not clean up an empty startingLocationsByNation[nationName] set - not worth it } + /** Removes all starting positions for [nationName], maintaining the transients */ + fun removeStartingLocations(nationName: String) { + if (startingLocationsByNation[nationName] == null) return + for (tile in startingLocationsByNation[nationName]!!) { + startingLocations.remove(StartingLocation(tile.position, nationName)) + } + startingLocationsByNation[nationName]!!.clear() + } + /** Clears starting positions, e.g. after GameStarter is done with them. Does not clear the pseudo-improvements. */ fun clearStartingLocations() { startingLocations.clear() startingLocationsByNation.clear() } + /** Set a continent id for each tile, so we can quickly see which tiles are connected. + * Can also be called on saved maps. + * @throws Exception when any land tile already has a continent ID + */ + fun assignContinents() { + var landTiles = values.filter { it.isLand && !it.isImpassible() } + var currentContinent = 0 + + while (landTiles.any()) { + val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() } + bfs.stepToEnd() + bfs.getReachedTiles().forEach { + it.setContinent(currentContinent) + } + val continent = bfs.getReachedTiles() + continentSizes[currentContinent] = continent.size + + currentContinent++ + landTiles = landTiles.filter { it !in continent } + } + } + //endregion } diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt index aeaa7b4cc9..f043f8d149 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt @@ -74,7 +74,7 @@ class MapGenerator(val ruleset: Ruleset) { spawnIce(map) } runAndMeasure("assignContinents") { - assignContinents(map) + map.assignContinents() } runAndMeasure("NaturalWonderGenerator") { NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map) @@ -463,32 +463,6 @@ class MapGenerator(val ruleset: Ruleset) { tile.terrainFeatures.add(Constants.ice) } } - - /** Set a continent id for each tile, so we can quickly see which tiles are connected. - * Can also be called on saved maps. - * @throws Exception when any land tile already has a continent ID - */ - fun assignContinents(tileMap: TileMap) { - var landTiles = tileMap.values - .filter { it.isLand && !it.isImpassible()} - var currentContinent = 0 - - while (landTiles.any()) { - val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() } - bfs.stepToEnd() - bfs.getReachedTiles().forEach { - it.setContinent(currentContinent) - } - val continent = bfs.getReachedTiles() - tileMap.continentSizes[currentContinent] = continent.size - if (continent.size > 20) { - tileMap.landTilesInBigEnoughGroup.addAll(continent) - } - - currentContinent++ - landTiles = landTiles.filter { it !in continent } - } - } } class MapGenerationRandomness { diff --git a/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt index 561574f737..50ba8db7da 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt @@ -54,6 +54,15 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration private fun Unique.getIntParam(index: Int) = params[index].toInt() private fun spawnSpecificWonder(tileMap: TileMap, wonder: Terrain): Boolean { + val continentsRelevant = wonder.hasUnique(UniqueType.NaturalWonderLargerLandmass) || + wonder.hasUnique(UniqueType.NaturalWonderSmallerLandmass) + val sortedContinents = if (continentsRelevant) + tileMap.continentSizes.asSequence() + .sortedByDescending { it.value } + .map { it.key } + .toList() + else listOf() + val suitableLocations = tileMap.values.filter { tile-> tile.resource == null && wonder.occursOn.contains(tile.getLastTerrain().name) && @@ -71,13 +80,12 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration } count in unique.getIntParam(0)..unique.getIntParam(1) } - UniqueType.NaturalWonderLandmass -> { - val sortedContinents = tileMap.continentSizes.asSequence() - .sortedByDescending { it.value } - .map { it.key } - .toList() + UniqueType.NaturalWonderSmallerLandmass -> { tile.getContinent() !in sortedContinents.take(unique.getIntParam(0)) } + UniqueType.NaturalWonderLargerLandmass -> { + tile.getContinent() in sortedContinents.take(unique.getIntParam(0)) + } UniqueType.NaturalWonderLatitude -> { val lower = tileMap.maxLatitude * unique.getIntParam(0) * 0.01f val upper = tileMap.maxLatitude * unique.getIntParam(1) * 0.01f @@ -168,7 +176,9 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration private fun TileInfo.matchesWonderFilter(filter: String) = when (filter) { "Elevated" -> baseTerrain == Constants.mountain || isHill() "Water" -> isWater + "Land" -> isLand "Hill" -> isHill() + naturalWonder -> true in allTerrainFeatures -> getLastTerrain().name == filter else -> baseTerrain == filter } diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt index 048b0ba61a..0c3a7735f5 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt @@ -92,14 +92,11 @@ enum class UniqueParameterType(val parameterName:String) { }, /** Used by NaturalWonderGenerator, only tests base terrain or a feature */ SimpleTerrain("simpleTerrain") { + private val knownValues = setOf("Elevated", "Water", "Land") override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueComplianceErrorSeverity? { - if (parameterText == "Elevated") return null - if (ruleset.terrains.values.any { - it.name == parameterText && - (it.type.isBaseTerrain || it.type == TerrainType.TerrainFeature) - }) - return null + if (parameterText in knownValues) return null + if (ruleset.terrains.containsKey(parameterText)) return null return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific } }, diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index ff3e66318c..405b87aa0b 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -103,14 +103,16 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget) { CityStateMilitaryUnits("Provides military units every ≈[amount] turns", UniqueTarget.CityState), // No conditional support as of yet CityStateUniqueLuxury("Provides a unique luxury", UniqueTarget.CityState), // No conditional support as of yet - NaturalWonderNeighborCount("Must be adjacent to [amount] [terrainFilter] tiles", UniqueTarget.Terrain), - NaturalWonderNeighborsRange("Must be adjacent to [amount] to [amount] [terrainFilter] tiles", UniqueTarget.Terrain), - NaturalWonderLandmass("Must not be on [amount] largest landmasses", UniqueTarget.Terrain), + NaturalWonderNeighborCount("Must be adjacent to [amount] [simpleTerrain] tiles", UniqueTarget.Terrain), + NaturalWonderNeighborsRange("Must be adjacent to [amount] to [amount] [simpleTerrain] tiles", UniqueTarget.Terrain), + NaturalWonderSmallerLandmass("Must not be on [amount] largest landmasses", UniqueTarget.Terrain), + NaturalWonderLargerLandmass("Must be on [amount] largest landmasses", UniqueTarget.Terrain), NaturalWonderLatitude("Occurs on latitudes from [amount] to [amount] percent of distance equator to pole", UniqueTarget.Terrain), NaturalWonderGroups("Occurs in groups of [amount] to [amount] tiles", UniqueTarget.Terrain), NaturalWonderConvertNeighbors("Neighboring tiles will convert to [baseTerrain]", UniqueTarget.Terrain), + // The "Except [terrainFilter]" could theoretically be implemented with a conditional - NaturalWonderConvertNeighborsExcept("Neighboring tiles except [terrainFilter] will convert to [baseTerrain]", UniqueTarget.Terrain), + NaturalWonderConvertNeighborsExcept("Neighboring tiles except [baseTerrain] will convert to [baseTerrain]", UniqueTarget.Terrain), TerrainGrantsPromotion("Grants [promotion] ([comment]) to adjacent [mapUnitFilter] units for the rest of the game", UniqueTarget.Terrain),