From a5a8ea13383cf3ac3c719a58e9b718d0e5b2e06b Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Thu, 23 Sep 2021 10:21:08 +0200 Subject: [PATCH] Natural Wonders un-hardcoded (#5279) * Natural Wonders un-hardcoded * Natural Wonders un-hardcoded - oops testing change revert * Natural Wonders un-hardcoded - tests OK * Natural Wonders un-hardcoded - requests * Natural Wonders un-hardcoded - hide from pedia * Natural Wonders un-hardcoded - readable unique --- .../jsons/Civ V - Vanilla/Terrains.json | 70 ++- core/src/com/unciv/logic/GameStarter.kt | 6 +- core/src/com/unciv/logic/map/BFS.kt | 15 +- core/src/com/unciv/logic/map/TileInfo.kt | 8 +- .../logic/map/mapgenerator/MapGenerator.kt | 11 +- .../mapgenerator/NaturalWonderGenerator.kt | 551 ++++++------------ .../unciv/models/ruleset/tile/TerrainType.kt | 10 +- .../com/unciv/models/ruleset/unique/Unique.kt | 6 +- .../ruleset/unique/UniqueParameterType.kt | 35 +- .../unciv/models/ruleset/unique/UniqueType.kt | 38 +- 10 files changed, 327 insertions(+), 423 deletions(-) diff --git a/android/assets/jsons/Civ V - Vanilla/Terrains.json b/android/assets/jsons/Civ V - Vanilla/Terrains.json index a7a6fb169e..24a53db967 100644 --- a/android/assets/jsons/Civ V - Vanilla/Terrains.json +++ b/android/assets/jsons/Civ V - Vanilla/Terrains.json @@ -204,6 +204,10 @@ "gold": 1, "science": 2, "occursOn": ["Ocean"], + "uniques": ["Must be adjacent to [1] to [6] [Coast] tiles", + "Must be adjacent to [6] [Water] tiles", + "Occurs on latitudes from [10] to [70] percent of distance equator to pole", + "Occurs in groups of [2] to [2] tiles"], "turnsInto": "Coast", "impassable": true, "unbuildable": true, @@ -215,6 +219,11 @@ "science": 2, "happiness": 3, "occursOn": ["Grassland","Plains","Tundra","Mountain"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] to [4] [Mountain] tiles", + "Must be adjacent to [3] to [6] [Elevated] tiles", + "Must be adjacent to [0] to [3] [Desert] tiles", + "Must be adjacent to [0] to [3] [Tundra] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -228,7 +237,9 @@ "turnsInto": "Plains", "impassable": true, "unbuildable": true, - "uniques": ["Grants 500 Gold to the first civilization to discover it"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [1] to [6] [Jungle] tiles", + "Grants 500 Gold to the first civilization to discover it"], "weight": 2 }, { // This will count as "Fresh water" in civ 6 @@ -239,7 +250,8 @@ "turnsInto": "Plains", "impassable": true, "unbuildable": true, - "uniques": ["Grants Rejuvenation (all healing effects doubled) to adjacent military land units for the rest of the game"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Grants Rejuvenation (all healing effects doubled) to adjacent military land units for the rest of the game"], "weight": 1 }, { @@ -248,6 +260,10 @@ "production": 2, "gold": 3, "occursOn": ["Plains","Desert","Tundra"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Grassland] tiles", + "Must be adjacent to [2] to [6] [Hill] tiles", + "Must be adjacent to [0] to [2] [Mountain] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -260,6 +276,13 @@ "culture": 3, "faith": 3, "occursOn": ["Grassland","Plains"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Tundra] tiles", + "Must be adjacent to [0] [Desert] tiles", + "Must be adjacent to [0] [Mountain] tiles", + "Must be adjacent to [0] [Marsh] tiles", + "Must be adjacent to [0] to [2] [Hill] tiles", + "Must not be on [1] largest landmasses"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -270,6 +293,9 @@ "type": "NaturalWonder", "science": 5, "occursOn": ["Ocean"], + "uniques": ["Must be adjacent to [1] to [6] [Coast] tiles", + "Must be adjacent to [0] [Ice] tiles", + "Neighboring tiles will convert to [Coast]"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -281,6 +307,9 @@ "food": 2, "gold": 5, "occursOn": ["Grassland"], + "uniques": ["Must be adjacent to [1] to [5] [Coast] tiles", + "Must be adjacent to [1] [Mountain] tiles", + "Neighboring tiles except [Mountain] will convert to [Coast]"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -291,6 +320,8 @@ "type": "NaturalWonder", "gold": 10, "occursOn": ["Plains","Mountain"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [1] to [6] [Hill] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -302,17 +333,26 @@ "gold": 2, "science": 3, "occursOn": ["Desert","Tundra"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Grassland] tiles", + "Must be adjacent to [0] to [2] [Mountain] tiles", + "Must be adjacent to [0] to [4] [Elevated] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, "weight": 10 }, + // G&K Wonders { "name": "Mount Kailash", "type": "NaturalWonder", "faith": 6, "happiness": 2, "occursOn": ["Plains","Grassland"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Marsh] tiles", + "Must be adjacent to [0] to [1] [Desert] tiles", + "Must be adjacent to [4] to [6] [Elevated] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -323,6 +363,11 @@ "type": "NaturalWonder", "faith": 8, "occursOn": ["Desert","Plains"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Grassland] tiles", + "Must be adjacent to [0] [Tundra] tiles", + "Must be adjacent to [0] [Marsh] tiles", + "Must be adjacent to [3] to [6] [Desert] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -335,22 +380,31 @@ "faith": 4, "happiness": 2, "occursOn": ["Plains","Grassland"], + "uniques": ["Must be adjacent to [0] [Tundra] tiles", + "Must be adjacent to [0] [Desert] tiles", + "Must be adjacent to [0] [Marsh] tiles", + "Must not be on [1] largest landmasses"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, "weight": 10 - }, + }, { "name": "Uluru", "type": "NaturalWonder", "food": 2, "faith": 6, "occursOn": ["Plains","Desert"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] [Grassland] tiles", + "Must be adjacent to [0] [Tundra] tiles", + "Must be adjacent to [0] [Marsh] tiles", + "Must be adjacent to [3] to [6] [Plains] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, "weight": 10 - } + } /* // BNW wonders { @@ -358,6 +412,8 @@ "type": "NaturalWonder", "production": 6, "occursOn": ["Plains","Desert"], + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [0] to [2] [Mountain] tiles"], "turnsInto": "Plains", "impassable": true, "unbuildable": true, @@ -368,6 +424,7 @@ "type": "NaturalWonder", "food": 6, "occursOn": ["Plains"], + "uniques": ["Must be adjacent to [0] [Coast] tiles"], "turnsInto": "Mountain", "impassable": true, "unbuildable": true, @@ -383,7 +440,10 @@ "turnsInto": "Mountain", "impassable": true, "unbuildable": true, - "uniques": ["Grants Altitude Training (double movement and +10% Strength in hills) to adjacent land units for the rest of the game"], //ToDo + "uniques": ["Must be adjacent to [0] [Coast] tiles", + "Must be adjacent to [2] to [6] [Hill] tiles", + "Must be adjacent to [0] to [2] [Mountain] tiles", + "Grants Altitude Training (double movement and +10% Strength in hills) to adjacent land units for the rest of the game"], //ToDo "weight": 10 } */ diff --git a/core/src/com/unciv/logic/GameStarter.kt b/core/src/com/unciv/logic/GameStarter.kt index 607462080e..3398604a3b 100644 --- a/core/src/com/unciv/logic/GameStarter.kt +++ b/core/src/com/unciv/logic/GameStarter.kt @@ -84,10 +84,10 @@ object GameStarter { addCivStats(gameInfo) } - runAndMeasure("assignContinents?") { - if (tileMap.continentSizes.isEmpty()) // Probably saved map without continent data + if (tileMap.continentSizes.isEmpty()) // Probably saved map without continent data + runAndMeasure("assignContinents") { mapGen.assignContinents(tileMap) - } + } runAndMeasure("addCivStartingUnits") { // and only now do we add units for everyone, because otherwise both the gameInfo.setTransients() and the placeUnit will both add the unit to the civ's unit list! diff --git a/core/src/com/unciv/logic/map/BFS.kt b/core/src/com/unciv/logic/map/BFS.kt index b8587f5239..6c37d73f20 100644 --- a/core/src/com/unciv/logic/map/BFS.kt +++ b/core/src/com/unciv/logic/map/BFS.kt @@ -11,7 +11,7 @@ class BFS( ) { /** Maximum number of tiles to search */ var maxSize = Int.MAX_VALUE - + /** remaining tiles to check */ private val tilesToCheck = ArrayDeque(37) // needs resize at distance 4 @@ -23,13 +23,10 @@ class BFS( tilesReached[startingPoint] = startingPoint } - /** Process fully until there's nowhere left to check - * Optionally assigns a continent ID as it goes */ - fun stepToEnd(continent: Int? = null) { - if (continent != null) - startingPoint.setContinent(continent) + /** Process fully until there's nowhere left to check */ + fun stepToEnd() { while (!hasEnded()) - nextStep(continent) + nextStep() } /** @@ -49,15 +46,13 @@ class BFS( * * Will do nothing when [hasEnded] returns `true` */ - fun nextStep(continent: Int? = null) { + fun nextStep() { if (tilesReached.size >= maxSize) { tilesToCheck.clear(); return } val current = tilesToCheck.removeFirstOrNull() ?: return for (neighbor in current.neighbors) { if (neighbor !in tilesReached && predicate(neighbor)) { tilesReached[neighbor] = current tilesToCheck.add(neighbor) - if (continent != null) - neighbor.setContinent(continent) } } } diff --git a/core/src/com/unciv/logic/map/TileInfo.kt b/core/src/com/unciv/logic/map/TileInfo.kt index 7e313eaadd..250ac8bb25 100644 --- a/core/src/com/unciv/logic/map/TileInfo.kt +++ b/core/src/com/unciv/logic/map/TileInfo.kt @@ -778,7 +778,13 @@ open class TileInfo { } } - // Should only be set once at map generation + /** + * Assign a continent ID to this tile. + * + * Should only be set once at map generation. + * @param continent Numeric ID >= 0 + * @throws Exception when tile already has a continent ID + */ fun setContinent(continent: Int) { if (this.continent != -1) throw Exception("Continent already assigned @ $position") diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt index 7edf695579..aeaa7b4cc9 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt @@ -464,8 +464,10 @@ class MapGenerator(val ruleset: Ruleset) { } } - // Set a continent id for each tile, so we can quickly see which tiles are connected. - // Can also be called on saved maps + /** 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()} @@ -473,7 +475,10 @@ class MapGenerator(val ruleset: Ruleset) { while (landTiles.any()) { val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() } - bfs.stepToEnd(currentContinent) + bfs.stepToEnd() + bfs.getReachedTiles().forEach { + it.setContinent(currentContinent) + } val continent = bfs.getReachedTiles() tileMap.continentSizes[currentContinent] = continent.size if (continent.size > 20) { diff --git a/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt index 6a5a171a9c..561574f737 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/NaturalWonderGenerator.kt @@ -6,11 +6,17 @@ import com.unciv.logic.map.TileMap import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.tile.Terrain import com.unciv.models.ruleset.tile.TerrainType +import com.unciv.models.ruleset.unique.Unique +import com.unciv.models.ruleset.unique.UniqueType import kotlin.math.abs import kotlin.math.round class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGenerationRandomness) { + private val allTerrainFeatures = ruleset.terrains.values + .filter { it.type == TerrainType.TerrainFeature } + .map { it.name }.toSet() + /* https://gaming.stackexchange.com/questions/95095/do-natural-wonders-spawn-more-closely-to-city-states/96479 https://www.reddit.com/r/civ/comments/1jae5j/information_on_the_occurrence_of_natural_wonders/ @@ -22,18 +28,19 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration // number of Natural Wonders scales linearly with mapRadius as #wonders = mapRadius * 0.13133208 - 0.56128831 val numberToSpawn = round(mapRadius * 0.13133208f - 0.56128831f).toInt() - val toBeSpawned = ArrayList() + val spawned = mutableListOf() val allNaturalWonders = ruleset.terrains.values .filter { it.type == TerrainType.NaturalWonder }.toMutableList() - while (allNaturalWonders.isNotEmpty() && toBeSpawned.size < numberToSpawn) { - val totalWeight = allNaturalWonders.map { it.weight }.sum().toFloat() + while (allNaturalWonders.isNotEmpty() && spawned.size < numberToSpawn) { + val totalWeight = allNaturalWonders.sumOf { it.weight }.toFloat() val random = randomness.RNG.nextDouble() var sum = 0f for (wonder in allNaturalWonders) { sum += wonder.weight / totalWeight if (random <= sum) { - toBeSpawned.add(wonder) + if (spawnSpecificWonder(tileMap, wonder)) + spawned.add(wonder) allNaturalWonders.remove(wonder) break } @@ -41,391 +48,183 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration } if (MapGenerator.consoleOutput) - println("Natural Wonders for this game: $toBeSpawned") - - for (wonder in toBeSpawned) { - when (wonder.name) { - Constants.barringerCrater -> spawnBarringerCrater(tileMap) - Constants.mountFuji -> spawnMountFuji(tileMap) - Constants.grandMesa -> spawnGrandMesa(tileMap) - Constants.greatBarrierReef -> spawnGreatBarrierReef(tileMap) - Constants.krakatoa -> spawnKrakatoa(tileMap) - Constants.rockOfGibraltar -> spawnRockOfGibraltar(tileMap) - Constants.oldFaithful -> spawnOldFaithful(tileMap) - Constants.cerroDePotosi -> spawnCerroDePotosi(tileMap) - Constants.elDorado -> spawnElDorado(tileMap) - Constants.fountainOfYouth -> spawnFountainOfYouth(tileMap) - Constants.mountKailash -> spawnMountKailash(tileMap) - Constants.mountSinai -> spawnMountSinai(tileMap) - Constants.sriPada -> spawnSriPada(tileMap) - Constants.uluru -> spawnUluru(tileMap) - /* - Constants.kingSolomonsMines -> spawnSolomonMines(tileMap) - Constants.lakeVictoria -> spawnLakeVictoria(tileMap) - Constants.mountKilimanjaro -> spawnMountKilimanjaro(tileMap) - */ - } - } + println("Natural Wonders for this game: $spawned") } - private fun trySpawnOnSuitableLocation(suitableLocations: List, wonder: Terrain): TileInfo? { - if (suitableLocations.isNotEmpty()) { + private fun Unique.getIntParam(index: Int) = params[index].toInt() + + private fun spawnSpecificWonder(tileMap: TileMap, wonder: Terrain): Boolean { + val suitableLocations = tileMap.values.filter { tile-> + tile.resource == null && + wonder.occursOn.contains(tile.getLastTerrain().name) && + wonder.uniqueObjects.all { unique -> + when (unique.type) { + UniqueType.NaturalWonderNeighborCount -> { + val count = tile.neighbors.count { + it.matchesWonderFilter(unique.params[1]) + } + count == unique.getIntParam(0) + } + UniqueType.NaturalWonderNeighborsRange -> { + val count = tile.neighbors.count { + it.matchesWonderFilter(unique.params[2]) + } + count in unique.getIntParam(0)..unique.getIntParam(1) + } + UniqueType.NaturalWonderLandmass -> { + val sortedContinents = tileMap.continentSizes.asSequence() + .sortedByDescending { it.value } + .map { it.key } + .toList() + 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 + abs(tile.latitude) in lower..upper + } + else -> true + } + } + } + + return trySpawnOnSuitableLocation(suitableLocations, wonder) + } + + private fun trySpawnOnSuitableLocation(suitableLocations: List, wonder: Terrain): Boolean { + val minGroupSize: Int + val maxGroupSize: Int + val groupUnique = wonder.getMatchingUniques(UniqueType.NaturalWonderGroups).firstOrNull() + if (groupUnique == null) { + minGroupSize = 1 + maxGroupSize = 1 + } else { + minGroupSize = groupUnique.getIntParam(0) + maxGroupSize = groupUnique.getIntParam(1) + } + val targetGroupSize = if (minGroupSize == maxGroupSize) maxGroupSize + else (minGroupSize..maxGroupSize).random(randomness.RNG) + + var convertNeighborsExcept: String? = null + var convertUnique = wonder.getMatchingUniques(UniqueType.NaturalWonderConvertNeighbors).firstOrNull() + var convertNeighborsTo = convertUnique?.params?.get(0) + if (convertNeighborsTo == null) { + convertUnique = wonder.getMatchingUniques(UniqueType.NaturalWonderConvertNeighborsExcept).firstOrNull() + convertNeighborsExcept = convertUnique?.params?.get(0) + convertNeighborsTo = convertUnique?.params?.get(1) + } + + if (suitableLocations.size >= minGroupSize) { val location = suitableLocations.random(randomness.RNG) - clearTile(location) - location.naturalWonder = wonder.name - location.baseTerrain = wonder.turnsInto!! - return location + val list = mutableListOf(location) + while (list.size < targetGroupSize) { + val allNeighbors = list.flatMap { it.neighbors }.minus(list).toHashSet() + val candidates = suitableLocations.filter { it in allNeighbors } + if (candidates.isEmpty()) break + list.add(candidates.random(randomness.RNG)) + } + if (list.size >= minGroupSize) { + list.forEach { + clearTile(it) + it.naturalWonder = wonder.name + it.baseTerrain = wonder.turnsInto!! + } + if (convertNeighborsTo != null) { + for (tile in location.neighbors) { + if (tile.baseTerrain == convertNeighborsTo) continue + if (tile.baseTerrain == convertNeighborsExcept) continue + if (convertNeighborsTo == Constants.coast) + for (neighbor in tile.neighbors) { + // This is so we don't have this tile turn into Coast, and then it's touching a Lake tile. + // We just turn the lake tiles into this kind of tile. + if (neighbor.baseTerrain == Constants.lakes) { + neighbor.baseTerrain = tile.baseTerrain + neighbor.setTerrainTransients() + } + } + tile.baseTerrain = convertNeighborsTo + clearTile(tile) + } + } + if (MapGenerator.consoleOutput) + println("Natural Wonder ${wonder.name} @${location.position}") + + return true + } } if (MapGenerator.consoleOutput) println("No suitable location for ${wonder.name}") - return null + return false } - - /* - Barringer Crater: Must be in tundra or desert; cannot be adjacent to grassland; can be adjacent to a maximum - of 2 mountains and a maximum of 4 hills and mountains; avoids oceans; becomes mountain - */ - private fun spawnBarringerCrater(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.barringerCrater]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.grassland } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } <= 2 - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain || neighbor.isHill() } <= 4 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Mt. Fuji: Must be in grass or plains; avoids oceans and the biggest landmass; cannot be adjacent to tundra, - desert, marsh, or mountains;can be adjacent to a maximum of 2 hills; becomes mountain - // ToDo: avoids the biggest landmass - */ - private fun spawnMountFuji(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.mountFuji]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.tundra } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.desert } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } - && it.neighbors.none { neighbor -> neighbor.getLastTerrain().name == Constants.marsh } - && it.neighbors.count { neighbor -> neighbor.isHill() } <= 2 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Grand Mesa: Must be in plains, desert, or tundra, and must be adjacent to at least 2 hills; - cannot be adjacent to grass; can be adjacent to a maximum of 2 mountains; avoids oceans; becomes mountain - */ - private fun spawnGrandMesa(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.grandMesa]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.count { neighbor -> neighbor.isHill() } >= 2 - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.grassland } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } <= 2 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Great Barrier Reef: Specifics currently unknown; - Assumption: at least 1 neighbour coast; no tundra; at least 1 neighbour coast; becomes coast - */ - private fun spawnGreatBarrierReef(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.greatBarrierReef]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && abs(it.latitude) > tileMap.maxLatitude * 0.1 - && abs(it.latitude) < tileMap.maxLatitude * 0.7 - && it.neighbors.any { it.baseTerrain == Constants.coast } - && it.neighbors.all { neighbor -> neighbor.isWater } - && it.neighbors.any { neighbor -> - neighbor.resource == null && neighbor.improvement == null - && wonder.occursOn.contains(neighbor.getLastTerrain().name) - && neighbor.neighbors.all { it.isWater } - } - } - - val location = trySpawnOnSuitableLocation(suitableLocations, wonder) - if (location != null) { - val possibleLocations = location.neighbors - .filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.all { it.isWater } - }.toList() - trySpawnOnSuitableLocation(possibleLocations, wonder) - } - } - - /* - Krakatoa: Must spawn in the ocean next to at least 1 shallow water tile; cannot be adjacent - to ice; changes tiles around it to shallow water; mountain - */ - private fun spawnKrakatoa(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.krakatoa]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.any { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.none { neighbor -> neighbor.getLastTerrain().name == Constants.ice } - } - - val location = trySpawnOnSuitableLocation(suitableLocations, wonder) - if (location != null) { - for (tile in location.neighbors) { - if (tile.baseTerrain == Constants.coast) continue - tile.baseTerrain = Constants.coast - clearTile(tile) - } - } - } - - /* - Rock of Gibraltar: Specifics currently unknown - Assumption: spawn on grassland, at least 1 coast and 1 mountain adjacent; - turn neighbours into coast) - */ - private fun spawnRockOfGibraltar(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.rockOfGibraltar]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.any { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } == 1 - } - - val location = trySpawnOnSuitableLocation(suitableLocations, wonder) - if (location != null) { - for (tile in location.neighbors) { - if (tile.baseTerrain == Constants.coast) continue - if (tile.baseTerrain == Constants.mountain) continue - for (neighbor in tile.neighbors) - // This is so we don't have this tile turn into Coast, and then it's touching a Lake tile. - // We just turn the lake tiles into this kind of tile. - if (neighbor.baseTerrain == Constants.lakes) { - neighbor.baseTerrain = tile.baseTerrain - neighbor.setTerrainTransients() - } - - tile.baseTerrain = Constants.coast - clearTile(tile) - } - } - } - - /* - Old Faithful: Must be adjacent to at least 3 hills and mountains; cannot be adjacent to - more than 4 mountains, and cannot be adjacent to more than 3 desert or 3 tundra tiles; - avoids oceans; becomes mountain - */ - private fun spawnOldFaithful(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.oldFaithful]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } <= 4 - && it.neighbors.count { neighbor -> - neighbor.getBaseTerrain().name == Constants.mountain || - neighbor.isHill() - } >= 3 - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.desert } <= 3 - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.tundra } <= 3 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Cerro de Potosi: Must be adjacent to at least 1 hill; avoids oceans; becomes mountain - */ - private fun spawnCerroDePotosi(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.cerroDePotosi]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.any { neighbor -> neighbor.isHill() } - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - El Dorado: Must be next to at least 1 jungle tile; avoids oceans; becomes flatland plains - */ - private fun spawnElDorado(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.elDorado]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.any { neighbor -> neighbor.getLastTerrain().name == Constants.jungle } - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Fountain of Youth: Avoids oceans; becomes flatland plains - */ - private fun spawnFountainOfYouth(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.fountainOfYouth]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - // G&K Natural Wonders - - /* - Mount Kailash: Must be in plains or grassland, and must be adjacent to at least 4 hills and/or mountains; - cannot be adjacent to marshes; can be adjacent to a maximum of 1 desert tile; avoids oceans; becomes mountain - */ - private fun spawnMountKailash(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.mountKailash]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.marsh } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain || neighbor.isHill() } >= 4 - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.desert} <= 1 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Mount Sinai: Must be in plains or desert, and must be adjacent to a minimum of 3 desert tiles; - cannot be adjacent to tundra, marshes, or grassland; avoids oceans; becomes mountain - */ - private fun spawnMountSinai(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.mountSinai]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.marsh } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.tundra } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.grassland } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.desert } >= 3 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Sri Pada: Must be in a grass or plains; cannot be adjacent to desert, tundra, or marshes; - avoids the biggest landmass ; becomes mountain - // ToDo: avoids the biggest landmass - */ - private fun spawnSriPada(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.sriPada]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.desert } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.tundra } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.marsh } - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Uluru: Must be in plains or desert, and must be adjacent to a minimum of 3 plains tiles; - cannot be adjacent to grassland, tundra, or marshes; avoids oceans; becomes mountain - */ - private fun spawnUluru(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.uluru]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.grassland } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.marsh } - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.tundra } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.plains } >= 3 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - //BNW Natural Wonders - /* - - /* - King Solomon's Mines: Cannot be adjacent to more than 2 mountains; avoids oceans; becomes flatland plains - */ - private fun spawnSolomonMines(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.kingSolomonsMines]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } <= 2 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Lake Victoria: Avoids oceans; becomes flatland plains - */ - private fun spawnLakeVictoria(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.lakeVictoria]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - /* - Mount Kilimanjaro: Must be in plains or grassland, and must be adjacent to at least 2 hills; - cannot be adjacent to more than 2 mountains; avoids oceans; becomes mountain - */ - private fun spawnMountKilimanjaro(tileMap: TileMap) { - val wonder = ruleset.terrains[Constants.mountKilimanjaro]!! - val suitableLocations = tileMap.values.filter { - it.resource == null - && wonder.occursOn.contains(it.getLastTerrain().name) - && it.neighbors.none { neighbor -> neighbor.getBaseTerrain().name == Constants.coast } - && it.neighbors.count { neighbor -> neighbor.isHill() } >= 2 - && it.neighbors.count { neighbor -> neighbor.getBaseTerrain().name == Constants.mountain } <= 2 - } - - trySpawnOnSuitableLocation(suitableLocations, wonder) - } - - */ - private fun clearTile(tile: TileInfo){ tile.terrainFeatures.clear() tile.resource = null tile.improvement = null tile.setTerrainTransients() } + + private fun TileInfo.matchesWonderFilter(filter: String) = when (filter) { + "Elevated" -> baseTerrain == Constants.mountain || isHill() + "Water" -> isWater + "Hill" -> isHill() + in allTerrainFeatures -> getLastTerrain().name == filter + else -> baseTerrain == filter + } + + /* + Barringer Crater: Must be in tundra or desert; cannot be adjacent to grassland; can be adjacent to a maximum + of 2 mountains and a maximum of 4 hills and mountains; avoids oceans; becomes mountain + + Grand Mesa: Must be in plains, desert, or tundra, and must be adjacent to at least 2 hills; + cannot be adjacent to grass; can be adjacent to a maximum of 2 mountains; avoids oceans; becomes mountain + + Mt. Fuji: Must be in grass or plains; avoids oceans and the biggest landmass; cannot be adjacent to tundra, + desert, marsh, or mountains;can be adjacent to a maximum of 2 hills; becomes mountain + + Great Barrier Reef: Specifics currently unknown; + Assumption: at least 1 neighbour coast; no tundra; at least 1 neighbour coast; becomes coast + + Krakatoa: Must spawn in the ocean next to at least 1 shallow water tile; cannot be adjacent + to ice; changes tiles around it to shallow water; mountain + + Rock of Gibraltar: Specifics currently unknown + Assumption: spawn on grassland, at least 1 coast and 1 mountain adjacent; + turn neighbours into coast) + + Old Faithful: Must be adjacent to at least 3 hills and mountains; cannot be adjacent to + more than 4 mountains, and cannot be adjacent to more than 3 desert or 3 tundra tiles; + avoids oceans; becomes mountain + + Cerro de Potosi: Must be adjacent to at least 1 hill; avoids oceans; becomes mountain + + El Dorado: Must be next to at least 1 jungle tile; avoids oceans; becomes flatland plains + + Fountain of Youth: Avoids oceans; becomes flatland plains + + // G&K Natural Wonders + + Mount Kailash: Must be in plains or grassland, and must be adjacent to at least 4 hills and/or mountains; + cannot be adjacent to marshes; can be adjacent to a maximum of 1 desert tile; avoids oceans; becomes mountain + + Mount Sinai: Must be in plains or desert, and must be adjacent to a minimum of 3 desert tiles; + cannot be adjacent to tundra, marshes, or grassland; avoids oceans; becomes mountain + + Sri Pada: Must be in a grass or plains; cannot be adjacent to desert, tundra, or marshes; + avoids the biggest landmass ; becomes mountain + + Uluru: Must be in plains or desert, and must be adjacent to a minimum of 3 plains tiles; + cannot be adjacent to grassland, tundra, or marshes; avoids oceans; becomes mountain + + //BNW Natural Wonders + + King Solomon's Mines: Cannot be adjacent to more than 2 mountains; avoids oceans; becomes flatland plains + + Lake Victoria: Avoids oceans; becomes flatland plains + + Mount Kilimanjaro: Must be in plains or grassland, and must be adjacent to at least 2 hills; + cannot be adjacent to more than 2 mountains; avoids oceans; becomes mountain + */ + } diff --git a/core/src/com/unciv/models/ruleset/tile/TerrainType.kt b/core/src/com/unciv/models/ruleset/tile/TerrainType.kt index 8548a949d5..950f36faa9 100644 --- a/core/src/com/unciv/models/ruleset/tile/TerrainType.kt +++ b/core/src/com/unciv/models/ruleset/tile/TerrainType.kt @@ -1,8 +1,8 @@ package com.unciv.models.ruleset.tile -enum class TerrainType { - Land, - Water, - TerrainFeature, - NaturalWonder +enum class TerrainType(val isBaseTerrain: Boolean) { + Land(true), + Water(true), + TerrainFeature(false), + NaturalWonder(false) } diff --git a/core/src/com/unciv/models/ruleset/unique/Unique.kt b/core/src/com/unciv/models/ruleset/unique/Unique.kt index a956d9c963..acb1d93619 100644 --- a/core/src/com/unciv/models/ruleset/unique/Unique.kt +++ b/core/src/com/unciv/models/ruleset/unique/Unique.kt @@ -26,7 +26,7 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s /** We can't save compliance errors in the unique, since it's ruleset-dependant */ fun matches(uniqueType: UniqueType, ruleset: Ruleset) = isOfType(uniqueType) && uniqueType.getComplianceErrors(this, ruleset).isEmpty() - + // This function will get LARGE, as it will basically check for all conditionals if they apply // This will require a lot of parameters to be passed (attacking unit, tile, defending unit, civInfo, cityInfo, ...) // I'm open for better ideas, but this was the first thing that I could think of that would @@ -37,7 +37,7 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s } return true } - + private fun conditionalApplies( condition: Unique, civInfo: CivilizationInfo? = null, @@ -53,6 +53,8 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s else -> false } } + + override fun toString() = if (type == null) "\"$text\"" else "$type (\"$text\")" } diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt index 6bdb31fc80..be0b646335 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueParameterType.kt @@ -1,6 +1,8 @@ package com.unciv.models.ruleset.unique import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.tile.ResourceType +import com.unciv.models.ruleset.tile.TerrainType // parameterName values should be compliant with autogenerated values in TranslationFileWriter.generateStringsFromJSONs // Eventually we'll merge the translation generation to take this as the source of that @@ -13,7 +15,7 @@ enum class UniqueParameterType(val parameterName:String) { } }, MapUnitFilter("mapUnitFilter"){ - val knownValues = setOf("Wounded", "Barbarians", "City-State", "Embarked", "Non-City") + private val knownValues = setOf("Wounded", "Barbarians", "City-State", "Embarked", "Non-City") override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueComplianceErrorSeverity? { if (parameterText in knownValues) return null @@ -23,7 +25,7 @@ enum class UniqueParameterType(val parameterName:String) { BaseUnitFilter("baseUnitFilter"){ // As you can see there is a difference between these and what's in unitTypeStrings (for translation) - // the goal is to unify, but for now this is the "real" list - val knownValues = setOf("All", "Melee", "Ranged", "Civilian", "Military", "Land", "Water", "Air", + private val knownValues = setOf("All", "Melee", "Ranged", "Civilian", "Military", "Land", "Water", "Air", "non-air", "Nuclear Weapon", "Great Person", "Religious") override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueComplianceErrorSeverity? { @@ -62,6 +64,33 @@ enum class UniqueParameterType(val parameterName:String) { return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific } }, + TerrainFilter("terrainFilter") { + private val knownValues = setOf("All", + "Coastal", "River", "Open terrain", "Rough terrain", "Water resource", + "Foreign Land", "Foreign", "Friendly Land", "Friendly", "Enemy Land", "Enemy") + override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): + UniqueType.UniqueComplianceErrorSeverity? { + if (parameterText in knownValues) return null + if (ruleset.terrains.containsKey(parameterText)) return null + if (TerrainType.values().any { parameterText == it.name }) return null + if (ruleset.tileResources.containsKey(parameterText)) return null + if (ResourceType.values().any { parameterText == it.name + " resource" }) return null + return UniqueType.UniqueComplianceErrorSeverity.WarningOnly + } + }, + /** Used by NaturalWonderGenerator, only tests base terrain or a feature */ + SimpleTerrain("simpleTerrain") { + 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 + return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific + } + }, Unknown("param") { override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): UniqueType.UniqueComplianceErrorSeverity? { @@ -115,4 +144,4 @@ class UniqueComplianceError( val parameterName: String, val acceptableParameterTypes: List, val errorSeverity: UniqueType.UniqueComplianceErrorSeverity -) \ No newline at end of file +) diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index e8c0a3759f..ebbdf0a23e 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -4,51 +4,51 @@ import com.unciv.models.ruleset.Ruleset import com.unciv.models.translations.getPlaceholderParameters import com.unciv.models.translations.getPlaceholderText +/** Buildings, units, nations, policies, religions, techs etc. + * Basically anything caught by CivInfo.getMatchingUniques. */ enum class UniqueTarget { - /** Buildings, units, nations, policies, religions, techs etc. - * Basically anything caught by CivInfo.getMatchingUniques. */ Global, - + // Civilization-specific Nation, Era, Tech, Policy, Belief, - + // City-specific Building, Wonder, - + // Unit-specific Unit, UnitType, Promotion, - + // Tile-specific Terrain, Improvement, Resource, Ruins, - + // Other CityState, - ModOptions, + ModOptions, Conditional, } enum class UniqueType(val text:String, vararg target: UniqueTarget) { - + Stats("[stats]", UniqueTarget.Global), StatsPerCity("[stats] [cityFilter]", UniqueTarget.Global), - + StatPercentBonus("[amount]% [Stat]", UniqueTarget.Global), ConsumesResources("Consumes [amount] [resource]", UniqueTarget.Improvement, UniqueTarget.Building, UniqueTarget.Unit), // No conditional support as of yet ProvidesResources("Provides [amount] [resource]", UniqueTarget.Improvement, UniqueTarget.Building), - + FreeUnits("[amount] units cost no maintenance", UniqueTarget.Global), UnitMaintenanceDiscount("[amount]% maintenance costs for [mapUnitFilter] units", UniqueTarget.Global), @@ -70,10 +70,18 @@ enum class UniqueType(val text:String, vararg target: UniqueTarget) { CityStateHappiness("Provides [amount] Happiness", UniqueTarget.CityState), 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), + 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), + NaturalWonderConvertNeighborsExcept("Neighboring tiles except [terrainFilter] will convert to [baseTerrain]", UniqueTarget.Terrain), + + ///// CONDITIONALS - + ConditionalWar("when at war", UniqueTarget.Conditional), ConditionalNotWar("when not at war", UniqueTarget.Conditional), ConditionalSpecialistCount("if this city has at least [amount] specialists", UniqueTarget.Conditional), @@ -128,4 +136,4 @@ enum class UniqueType(val text:String, vararg target: UniqueTarget) { } return errorList } -} \ No newline at end of file +}