Enhance modding freedom for Natural Wonders (#12062)

* Allow omitting `turnsInto` on Natural Wonders

* Support a wider range of parameters on NaturalWonderConvertNeighbors*

* Extend docDescription

* Good suggestion

Co-authored-by: Yair Morgenstern <yairm210@hotmail.com>

---------

Co-authored-by: Yair Morgenstern <yairm210@hotmail.com>
This commit is contained in:
SomeTroglodyte
2024-08-06 09:10:29 +02:00
committed by GitHub
parent 94c2d7f798
commit 65a99e23fd
8 changed files with 98 additions and 60 deletions

View File

@ -6,6 +6,7 @@ import com.unciv.logic.map.tile.Tile
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.StateForConditionals
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.utils.debug
@ -14,10 +15,6 @@ import kotlin.math.roundToInt
class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGenerationRandomness) {
private val allTerrainFeatures = ruleset.terrains.values
.filter { it.type == TerrainType.TerrainFeature }
.map { it.name }.toSet()
private val blockedTiles = HashSet<Tile>()
/*
@ -64,7 +61,7 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration
}
chosenWonders.sortBy { wonderCandidateTiles[it]!!.size }
for (wonder in chosenWonders) {
if (trySpawnOnSuitableLocation(wonderCandidateTiles[wonder]!!.filter { it !in blockedTiles }.toList(), wonder))
if (trySpawnOnSuitableLocation(wonderCandidateTiles[wonder]!!.filter { it !in blockedTiles }, wonder))
spawned.add(wonder)
}
@ -180,59 +177,78 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration
companion object {
fun placeNaturalWonder(wonder: Terrain, location: Tile) {
clearTile(location)
location.naturalWonder = wonder.name
if (wonder.turnsInto != null)
if (wonder.turnsInto != null) {
clearTile(location)
location.baseTerrain = wonder.turnsInto!!
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)
} else {
clearTile(location, wonder.occursOn)
}
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()
}
}
location.setConnectedByRiver(tile, false)
val conversionUniques = wonder.getMatchingUniques(UniqueType.NaturalWonderConvertNeighbors, StateForConditionals.IgnoreConditionals) +
wonder.getMatchingUniques(UniqueType.NaturalWonderConvertNeighborsExcept, StateForConditionals.IgnoreConditionals)
if (conversionUniques.none()) return
for (tile in location.neighbors) {
val state = StateForConditionals(tile = tile)
for (unique in conversionUniques) {
if (!unique.conditionalsApply(state)) continue
val convertTo = if (unique.type == UniqueType.NaturalWonderConvertNeighborsExcept) {
if (tile.matchesWonderFilter(unique.params[0])) continue
unique.params[1]
} else unique.params[0]
if (tile.baseTerrain == convertTo || convertTo in tile.terrainFeatures) continue
if (convertTo == Constants.lakes && tile.isCoastalTile()) continue
val terrainObject = location.ruleset.terrains[convertTo] ?: continue
if (terrainObject.type == TerrainType.TerrainFeature && tile.baseTerrain !in terrainObject.occursOn) continue
if (convertTo == Constants.coast)
removeLakesNextToFutureCoast(location, tile)
if (terrainObject.type.isBaseTerrain) {
clearTile(tile)
tile.baseTerrain = convertTo
}
if (terrainObject.type == TerrainType.TerrainFeature) {
clearTile(tile, tile.terrainFeatures)
tile.addTerrainFeature(convertTo)
}
tile.baseTerrain = convertNeighborsTo
clearTile(tile)
}
}
}
private fun clearTile(tile: Tile) {
tile.setTerrainFeatures(listOf())
// location is being converted to a NW, tile is a neighbor to be converted to coast: Ensure that coast won't show invalid rivers or coast touching lakes
private fun removeLakesNextToFutureCoast(location: Tile, tile: Tile) {
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) {
clearTile(neighbor)
neighbor.baseTerrain = tile.baseTerrain
neighbor.setTerrainTransients()
}
}
location.setConnectedByRiver(tile, false)
}
/** Implements [UniqueParameterType.SimpleTerrain][com.unciv.models.ruleset.unique.UniqueParameterType.SimpleTerrain] */
private fun Tile.matchesWonderFilter(filter: String) = when (filter) {
"Elevated" -> baseTerrain == Constants.mountain || isHill()
"Water" -> isWater
"Land" -> isLand
Constants.hill -> isHill()
naturalWonder -> true
lastTerrain.name -> true
else -> baseTerrain == filter
}
private fun clearTile(tile: Tile, exceptFeatures: List<String> = listOf()) {
if (tile.terrainFeatures.isNotEmpty() && exceptFeatures != tile.terrainFeatures)
tile.setTerrainFeatures(tile.terrainFeatures.filter { it in exceptFeatures })
tile.resource = null
tile.improvement = null
tile.removeImprovement()
tile.setTerrainTransients()
}
}
/** Implements [UniqueParameterType.SimpleTerrain][com.unciv.models.ruleset.unique.UniqueParameterType.SimpleTerrain] */
private fun Tile.matchesWonderFilter(filter: String) = when (filter) {
"Elevated" -> baseTerrain == Constants.mountain || isHill()
"Water" -> isWater
"Land" -> isLand
Constants.hill -> isHill()
naturalWonder -> true
in allTerrainFeatures -> lastTerrain.name == filter
else -> baseTerrain == filter
}
/*
Barringer Crater: Must be in tundra or desert; cannot be adjacent to grassland; can be adjacent to a maximum

View File

@ -10,9 +10,13 @@ object TileNormalizer {
if (tile.naturalWonder != null && !ruleset.terrains.containsKey(tile.naturalWonder))
tile.naturalWonder = null
if (tile.naturalWonder != null) {
if (tile.getNaturalWonder().turnsInto != null)
tile.baseTerrain = tile.getNaturalWonder().turnsInto!!
tile.setTerrainFeatures(listOf())
val wonderTerrain = tile.getNaturalWonder()
if (wonderTerrain.turnsInto != null) {
tile.baseTerrain = wonderTerrain.turnsInto!!
tile.removeTerrainFeatures()
} else {
tile.setTerrainFeatures(tile.terrainFeatures.filter { it in wonderTerrain.occursOn })
}
tile.resource = null
tile.clearImprovement()
}

View File

@ -25,7 +25,9 @@ class Terrain : RulesetStatsObject() {
/** For terrain features */
val occursOn = ArrayList<String>()
/** Used by Natural Wonders: it is the baseTerrain on top of which the Natural Wonder is placed */
/** Used by Natural Wonders: it is the baseTerrain on top of which the Natural Wonder is placed
* Omitting it means the Natural Wonder is placed on whatever baseTerrain the Tile already had (limited by occursOn)
*/
var turnsInto: String? = null
override fun getUniqueTarget() = UniqueTarget.Terrain

View File

@ -7,6 +7,7 @@ import com.unciv.models.ruleset.BeliefType
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.ruleset.tile.TerrainType
import com.unciv.models.ruleset.unique.UniqueParameterType.Companion.guessTypeForTranslationWriter
import com.unciv.models.ruleset.validation.Suppression
import com.unciv.models.stats.Stat
@ -366,11 +367,20 @@ enum class UniqueParameterType(
staticKnownValues + ruleset.terrains.keys
},
/** Used by [NaturalWonderGenerator.trySpawnOnSuitableLocation][com.unciv.logic.map.mapgenerator.NaturalWonderGenerator.trySpawnOnSuitableLocation], only tests base terrain */
/** Used by [NaturalWonderGenerator][com.unciv.logic.map.mapgenerator.NaturalWonderGenerator], only tests base terrain */
BaseTerrain("baseTerrain", Constants.grassland, "The name of any terrain that is a base terrain according to the json file") {
override fun getKnownValuesForAutocomplete(ruleset: Ruleset): Set<String> =
ruleset.terrains.filter { it.value.type.isBaseTerrain }.keys
},
/** Used by [UniqueType.NaturalWonderConvertNeighbors], only tests base terrain.
* - See [NaturalWonderGenerator.trySpawnOnSuitableLocation][com.unciv.logic.map.mapgenerator.NaturalWonderGenerator.trySpawnOnSuitableLocation] */
TerrainFeature("terrainFeature", Constants.hill, "The name of any terrain that is a terrain feature according to the json file") {
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):
UniqueType.UniqueParameterErrorSeverity? {
if (ruleset.terrains[parameterText]?.type == TerrainType.TerrainFeature) return null
return UniqueType.UniqueParameterErrorSeverity.RulesetSpecific
}
},
/** Used by: [UniqueType.LandUnitsCrossTerrainAfterUnitGained] (CivilizationInfo.addUnit),
* [UniqueType.ChangesTerrain] (MapGenerator.convertTerrains) */

View File

@ -534,9 +534,13 @@ enum class UniqueType(
NaturalWonderLargerLandmass("Must be on [amount] largest landmasses", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers),
NaturalWonderLatitude("Occurs on latitudes from [amount] to [amount] percent of distance equator to pole", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers),
NaturalWonderGroups("Occurs in groups of [amount] to [amount] tiles", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers),
NaturalWonderConvertNeighbors("Neighboring tiles will convert to [baseTerrain]", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers),
NaturalWonderConvertNeighbors("Neighboring tiles will convert to [baseTerrain/terrainFeature]", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers,
docDescription = "Supports conditionals that need only a Tile as context and nothing else, like `<with [n]% chance>`, and applies them per neighbor." +
"\nIf your mod renames Coast or Lakes, do not use this with one of these as parameter, as the code preventing artifacts won't work."),
// The "Except [terrainFilter]" could theoretically be implemented with a conditional
NaturalWonderConvertNeighborsExcept("Neighboring tiles except [baseTerrain] will convert to [baseTerrain]", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers),
NaturalWonderConvertNeighborsExcept("Neighboring tiles except [simpleTerrain] will convert to [baseTerrain/terrainFeature]", UniqueTarget.Terrain, flags = UniqueFlag.setOfHiddenToUsers,
docDescription = "Supports conditionals that need only a Tile as context and nothing else, like `<with [n]% chance>`, and applies them per neighbor." +
"\nIf your mod renames Coast or Lakes, do not use this with one of these as parameter, as the code preventing artifacts won't work."),
GrantsStatsToFirstToDiscover("Grants [stats] to the first civilization to discover it", UniqueTarget.Terrain),
// General terrain

View File

@ -479,9 +479,7 @@ class RulesetValidator(val ruleset: Ruleset) {
else if (baseTerrain.type == TerrainType.NaturalWonder)
lines.add("${terrain.name} occurs on natural wonder $baseTerrainName: Unsupported.", RulesetErrorSeverity.WarningOptionsOnly, terrain)
}
if (terrain.type == TerrainType.NaturalWonder) {
if (terrain.turnsInto == null)
lines.add("Natural Wonder ${terrain.name} is missing the turnsInto attribute!", sourceObject = terrain)
if (terrain.type == TerrainType.NaturalWonder && terrain.turnsInto != null) {
val baseTerrain = ruleset.terrains[terrain.turnsInto]
if (baseTerrain == null)
lines.add("${terrain.name} turns into terrain ${terrain.turnsInto} which does not exist!", sourceObject = terrain)
@ -740,8 +738,8 @@ class RulesetValidator(val ruleset: Ruleset) {
if (techColumn.columnNumber < 0)
lines.add("Tech Column number ${techColumn.columnNumber} is negative", sourceObject = null)
val buildingsWithoutAssignedCost = ruleset.buildings.values.filter {
it.cost == -1 && techColumn.techs.map { it.name }.contains(it.requiredTech) }.toList()
val buildingsWithoutAssignedCost = ruleset.buildings.values.filter { building ->
building.cost == -1 && techColumn.techs.map { it.name }.contains(building.requiredTech) }.toList()
val nonWondersWithoutAssignedCost = buildingsWithoutAssignedCost.filter { !it.isAnyWonder() }

View File

@ -13,7 +13,7 @@ Each terrain entry has the following structure:
| name | String | Required | [^A] |
| type | Enum | Required | Land, Water, TerrainFeature, NaturalWonder [^B] |
| occursOn | List of Strings | none | Only for terrain features and Natural Wonders: The baseTerrain it can be placed on |
| turnsInto | String | none | Only for NaturalWonder: the base terrain is changed to this after placing the Natural Wonder |
| turnsInto | String | none | Only for NaturalWonder: optional mandatory base terrain [^C] |
| weight | Integer | 10 | Only for NaturalWonder: _relative_ weight of being picked by the map generator |
| [`<stats>`](#general-stat) | Float | 0 | Per-turn yield or bonus yield for the tile |
| overrideStats | Boolean | false | If true, a feature's yields replace any yield from underlying terrain instead of adding to it |
@ -29,6 +29,7 @@ Each terrain entry has the following structure:
`River` is hardcoded to be used to look up a [Stats](../../uniques.md#global-uniques) unique to determine the bonuses an actual River provides (remember, rivers live on the edges not as terrain).
River should always be a TerrainFeature and have the same uniques the one in the vanilla rulesets has - if you change that, expect surprises.
[^B]: A base ruleset mod is always expected to provide at least one Land and at least one Water terrain. We do not support Land-only or Water-only mods, even if they might be possible to pull off.
[^C]: If set, the base terrain is changed to this after placing the Natural Wonder, and terrain features cleared. Otherwise, terrain features are reduced to only those present in occursOn.
## TileImprovements.json

View File

@ -2155,13 +2155,15 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
Applicable to: Terrain
??? example "Neighboring tiles will convert to [baseTerrain]"
??? example "Neighboring tiles will convert to [baseTerrain/terrainFeature]"
Supports conditionals that need only a Tile as context and nothing else, like `<with [n]% chance>`, and applies them per neighbor.
Example: "Neighboring tiles will convert to [Grassland]"
Applicable to: Terrain
??? example "Neighboring tiles except [baseTerrain] will convert to [baseTerrain]"
Example: "Neighboring tiles except [Grassland] will convert to [Grassland]"
??? example "Neighboring tiles except [simpleTerrain] will convert to [baseTerrain/terrainFeature]"
Supports conditionals that need only a Tile as context and nothing else, like `<with [n]% chance>`, and applies them per neighbor.
Example: "Neighboring tiles except [Elevated] will convert to [Grassland]"
Applicable to: Terrain
@ -3304,6 +3306,7 @@ Simple unique parameters are explained by mouseover. Complex parameters are expl
*[stats]: For example: `+2 Production, +3 Food`. Note that the stat names need to be capitalized!
*[stockpiledResource]: The name of any stockpiled resource.
*[tech]: The name of any tech.
*[terrainFeature]: The name of any terrain that is a terrain feature according to the json file.
*[tileFilter]: Anything that can be used either in an improvementFilter or in a terrainFilter can be used here, plus 'unimproved'
*[unitType]: Can be 'Land', 'Water', 'Air', any unit type, a filtering Unique on a unit type, or a multi-filter of these.
*[validationWarning]: Suppresses one specific Ruleset validation warning. This can specify the full text verbatim including correct upper/lower case, or it can be a wildcard case-insensitive simple pattern starting and ending in an asterisk ('*'). If the suppression unique is used within an object or as modifier (not ModOptions), the wildcard symbols can be omitted, as selectivity is better due to the limited scope.