Console: Improve civ activatetrigger command (#11676)

* Limit visibility inside devconsole package

* Make City an INamed to allow use of INamed-bounded generics on it

* CliInput class treats all DocConsole command line tokens, allows autocompleting quoted input to quoted full names

* Alternate, more flexible but also validating `civ activatetrigger` implementation

* Allow undisturbed display of square brackets in UncivTextField's

* Fix minor bug with OneTimeRevealSpecificMapTiles and move OneTimeReveal* implementations together
This commit is contained in:
SomeTroglodyte 2024-06-04 17:01:00 +02:00 committed by GitHub
parent a046e43dbf
commit 4e28b9e75c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 549 additions and 196 deletions

View File

@ -21,6 +21,7 @@ import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.ruleset.unit.BaseUnit
import com.unciv.models.stats.INamed
import com.unciv.models.stats.Stat
import java.util.UUID
import kotlin.math.roundToInt
@ -32,7 +33,7 @@ enum class CityFlags {
}
class City : IsPartOfGameInfoSerialization {
class City : IsPartOfGameInfoSerialization, INamed {
@Transient
lateinit var civ: Civilization
@ -54,7 +55,7 @@ class City : IsPartOfGameInfoSerialization {
var location: Vector2 = Vector2.Zero
var id: String = UUID.randomUUID().toString()
var name: String = ""
override var name: String = ""
var foundingCiv = ""
// This is so that cities in resistance that are recaptured aren't in resistance anymore
var previousOwner = ""
@ -87,7 +88,7 @@ class City : IsPartOfGameInfoSerialization {
var updateCitizens = false // flag so that on startTurn() the Governor reassigns Citizens
@delegate:Transient
val neighboringCities: List<City> by lazy {
val neighboringCities: List<City> by lazy {
civ.gameInfo.getCities().filter { it != this && it.getCenterTile().aerialDistanceTo(getCenterTile()) <= 8 }.toList()
}
@ -234,7 +235,7 @@ class City : IsPartOfGameInfoSerialization {
internal fun getMaxHealth() =
200 + cityConstructions.getBuiltBuildings().sumOf { it.cityHealth }
fun getStrength() = cityConstructions.getBuiltBuildings().sumOf { it.cityStrength }.toFloat()
fun getStrength() = cityConstructions.getBuiltBuildings().sumOf { it.cityStrength }.toFloat()
override fun toString() = name // for debug

View File

@ -538,17 +538,6 @@ object UniqueTriggerActivation {
}
}
UniqueType.OneTimeRevealEntireMap -> {
return {
if (notification != null) {
civInfo.addNotification(notification, LocationAction(tile?.position), NotificationCategory.General, NotificationIcon.Scout)
}
civInfo.gameInfo.tileMap.values.asSequence()
.forEach { it.setExplored(civInfo, true) }
true
}
}
UniqueType.UnitsGainPromotion -> {
val filter = unique.params[0]
val promotion = unique.params[1]
@ -746,6 +735,16 @@ object UniqueTriggerActivation {
}
}
UniqueType.OneTimeRevealEntireMap -> {
return {
if (notification != null) {
civInfo.addNotification(notification, LocationAction(tile?.position), NotificationCategory.General, NotificationIcon.Scout)
}
civInfo.gameInfo.tileMap.values.asSequence()
.forEach { it.setExplored(civInfo, true) }
true
}
}
UniqueType.OneTimeRevealSpecificMapTiles -> {
if (tile == null) return null
@ -763,10 +762,8 @@ object UniqueTriggerActivation {
if (explorableTiles.none())
return null
if (!isAll) {
explorableTiles.shuffled(tileBasedRandom)
explorableTiles = explorableTiles.take(amount.toInt())
}
if (!isAll)
explorableTiles = explorableTiles.shuffled(tileBasedRandom).take(amount.toInt())
return {
for (explorableTile in explorableTiles) {

View File

@ -783,7 +783,7 @@ enum class UniqueType(
OneTimeGainProphet("Gain enough Faith for [amount]% of a Great Prophet", UniqueTarget.Triggerable),
// todo: The "up to [All]" used in vanilla json is not nice to read. Split?
// Or just reword it without the 'up to', so it reads "Reveal [amount/'all'] [tileFilter] tiles within [amount] tiles"
OneTimeRevealSpecificMapTiles("Reveal up to [positiveAmount/'all'] [tileFilter] within a [amount] tile radius", UniqueTarget.Triggerable),
OneTimeRevealSpecificMapTiles("Reveal up to [positiveAmount/'all'] [tileFilter] within a [positiveAmount] tile radius", UniqueTarget.Triggerable),
OneTimeRevealCrudeMap("From a randomly chosen tile [positiveAmount] tiles away from the ruins, reveal tiles up to [positiveAmount] tiles away with [positiveAmount]% chance", UniqueTarget.Ruins),
OneTimeGlobalAlert("Triggers the following global alert: [comment]", UniqueTarget.Triggerable), // used in Policy
OneTimeGlobalSpiesWhenEnteringEra("Every major Civilization gains a spy once a civilization enters this era", UniqueTarget.Era),

View File

@ -2,6 +2,8 @@ package com.unciv.ui.components
import com.badlogic.gdx.Application
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.graphics.g2d.BitmapFont
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
@ -48,6 +50,24 @@ object UncivTextField {
if (KeyCharAndCode.TAB in keyShortcuts) return
super.next(up)
}
// Note - this way to force TextField to display `[]` characters normally is an incomplete hack.
// The complete way would either require overriding `updateDisplayText` which is private, or all methods calling it,
// which are many including the keyboard listener, or keep a separate font without markup enabled around and put that
// into the default style, including its own NativeBitmapFontData instance and texture - involves quite some redesign.
// That said, observing the deficiency is hard - the internal `glyphPositions` could theoretically get out of sync, affecting selection and caret display.
override fun layout() {
val oldEnable = style.font.data.markupEnabled
style.font.data.markupEnabled = false
super.layout()
style.font.data.markupEnabled = oldEnable
}
override fun drawText(batch: Batch, font: BitmapFont, x: Float, y: Float) {
val oldEnable = font.data.markupEnabled
font.data.markupEnabled = false
super.drawText(batch, font, x, y)
font.data.markupEnabled = oldEnable
}
}
val translatedHint = hint.tr()
textField.messageText = translatedHint

View File

@ -0,0 +1,277 @@
package com.unciv.ui.screens.devconsole
import com.badlogic.gdx.graphics.Color
import com.unciv.models.ruleset.IRulesetObject
import com.unciv.models.stats.INamed
import com.unciv.models.stats.Stat
import com.unciv.ui.screens.devconsole.CliInput.Companion.equals
/**
* Represents the method used to convert/display ruleset object (or other) names in console input.
* - Goal is to make them comparable, and to make parameter-delimiting spaces unambiguous, both in a user-friendly way.
* - Method 1: Everything is lowercase and spaces replaced with '-': `mechanized-infantry`.
* - Method 2: See [toQuotedRepresentation]: `"Mechanized Infantry"`, `"Ship of the Line"` (case from json except the first word which gets titlecased).
* - Note: Method 2 supports "open" quoting, that is, the closing quote is missing from parsed input, for autocomplete purposes. See [splitToCliInput]
* - Supports method-agnostic Comparison with other instances or with Strings, but efficient comparison in loops requires predetermining a consistent method throughout the loop.
* - Note: Method 2 requires case-insensitive comparison, while Method 1 does not, and a comparison must convert both sides using the same method. [compareTo] ensures that.
*/
internal class CliInput(
parameter: String,
method: Method? = null
) : Comparable<CliInput> {
enum class Method {
Dashed,
Quoted;
infix fun or(other: Method) = if (this == Dashed && other == Dashed) Dashed else Quoted
infix fun and(other: Method) = if (this == Quoted && other == Quoted) Quoted else Dashed
}
//////////////////////////////////////////////////////////////// region Fields and initialization
/** 'type'
* - [Dashed][Method.Dashed] means [content] is stored and presented lowercased and blanks converted to dashes
* - [Quoted][Method.Quoted] means [content] is stored titlecased and multiple consecutive blanks converted to one, and is parsed/presented with quotes
*/
val method: Method = method ?: if (parameter.hasLeadingQuote()) Method.Quoted else Method.Dashed
/** 'massaged' parameter text */
val content: String = when (this.method) {
Method.Dashed -> parameter.toDashedRepresentation()
Method.Quoted -> parameter.toQuotedRepresentation()
}
/** The original parameter
* - Unavoidable to get a quoted representation from an instance set to Dashed in [getAutocompleteString]
* - Also used for [originalLength], [compareTo], [startsWith], [hashCode]
*/
private val original: String = parameter
//endregion
//////////////////////////////////////////////////////////////// region Overrides
override fun toString() = when(method) {
Method.Dashed -> content
Method.Quoted -> "\"$content\""
}
/** Comparison for two [CliInput] instances.
* - Two [Method.Dashed] instances are compared directly as their [content] field is already [lowercase]'ed
* - Two [Method.Quoted] instances are compared with `ignoreCase = true`
* - For mixed methods, the Quoted side is converted to Dashed representation.
*/
override fun compareTo(other: CliInput): Int = when {
method == Method.Dashed && other.method == Method.Dashed ->
content.compareTo(other.content)
method == Method.Quoted && other.method == Method.Quoted ->
content.compareTo(other.content, ignoreCase = true)
method == Method.Dashed ->
content.compareTo(other.original.toDashedRepresentation())
else ->
original.toDashedRepresentation().compareTo(other.content)
}
/** Test equality for `this` parameter with either a [CliInput] or [String] [other]. Case-insensitive.
* @see compareTo
*/
override fun equals(other: Any?): Boolean = when {
this === other -> true
other is CliInput -> compareTo(other) == 0
other is String -> compareTo(other, method) == 0
else -> false
}
// Remember hashCode is not required to return different results for equals()==false,
// but required to return equal results for equals()==true
override fun hashCode() = getDashedRepresentation().hashCode()
//endregion
//////////////////////////////////////////////////////////////// region Private helpers
private fun getAutocompleteString(paramMethod: Method, upTo: Int = content.length, toAppend: String = ""): String {
if (paramMethod == Method.Dashed && method == Method.Dashed)
return content.substring(0, upTo.coerceAtMost(content.length)) + toAppend
val source = if (method == Method.Quoted) content else original.toQuotedRepresentation()
val suffix = if (toAppend.isNotEmpty()) "\"" + toAppend else ""
return "\"" + source.substring(0, upTo.coerceAtMost(source.length)) + suffix
}
private fun getDashedRepresentation() = when(method) {
Method.Dashed -> content
Method.Quoted -> original.toDashedRepresentation()
}
//endregion
//////////////////////////////////////////////////////////////// region Public methods
/** returns an equivalent instance with the specified method */
fun toMethod(method: Method) = if (this.method == method) this else CliInput(original, method)
operator fun compareTo(other: String) = compareTo(other, method)
fun compareTo(other: String, method: Method) = compareTo(CliInput(other, method))
/** Similar to [equals]/[compareTo] in [method] treatment, but does the comparison for the common prefix only */
fun startsWith(other: CliInput): Boolean = when {
method == Method.Dashed && other.method == Method.Dashed ->
content.startsWith(other.content)
method == Method.Quoted && other.method == Method.Quoted ->
content.startsWith(other.content, ignoreCase = true)
method == Method.Dashed ->
content.startsWith(other.original.toDashedRepresentation())
else ->
original.toDashedRepresentation().startsWith(other.content)
}
fun isEmpty() = content.isEmpty()
fun isNotEmpty() = content.isNotEmpty()
/** length of the original parameter, for autocomplete replacing */
fun originalLength() = original.length
/** original parameter with any outer quotes removed, for activatetrigger parameters */
fun originalUnquoted() = original.removeOuterQuotes()
/** Parses `this` parameter as an Int number.
* @throws ConsoleErrorException if the string is not a valid representation of a number. */
fun toInt(): Int = content.toIntOrNull() ?: throw ConsoleErrorException("'$this' is not a valid number.")
/** Parses `this` parameter as a Float number.
* @throws ConsoleErrorException if the string is not a valid representation of a number. */
fun toFloat(): Float = content.toFloatOrNull() ?: throw ConsoleErrorException("'$this' is not a valid number.")
/** Parses `this` parameter as the name of a [Stat].
* @throws ConsoleErrorException if the string is not a Stat name. */
fun toStat(): Stat = enumValueOrNull<Stat>() ?: throw ConsoleErrorException("'$this' is not an acceptable Stat.")
/** Finds an enum instance of type [T] whose name [equals] `this` parameter.
* @return `null` if not found. */
inline fun <reified T: Enum<T>> enumValueOrNull(): T? = enumValues<T>().firstOrNull { equals(it.name) }
/** Finds an enum instance of type [T] whose name [equals] `this` parameter.
* @throws ConsoleErrorException if not found. */
inline fun <reified T: Enum<T>> enumValue(): T = enumValueOrNull<T>()
?: throw ConsoleErrorException("'$this' is not a valid ${T::class.java.simpleName}. Options are: ${enumValues<T>()}.")
/** Finds the first entry that [equals] `this` parameter.
* @return `null` if not found. */
fun findOrNull(options: Iterable<String>): String? = options.firstOrNull { equals(it) }
/** Finds the first entry that [equals] `this` parameter.
* @throws ConsoleErrorException if not found. */
// YAGNI at time of writing, kept for symmetry
fun find(options: Iterable<String>, typeName: String): String = options.firstOrNull { equals(it) }
?: throw ConsoleErrorException("'$this' is not a valid $typeName. Options are: ${options.joinToStringLimited()}")
/** Finds the first entry whose [name][INamed.name] [equals] `this` parameter.
* @return `null` if not found. */
fun <T: INamed> findOrNull(options: Iterable<T>): T? = options.firstOrNull { equals(it.name) }
/** Finds the first entry whose [name][INamed.name] [equals] `this` parameter.
* @throws ConsoleErrorException if not found. */
inline fun <reified T: INamed> find(options: Iterable<T>): T = findOrNull(options)
?: throw ConsoleErrorException("'$this' is not a valid ${T::class.java.simpleName}. Options are: ${options.joinToStringLimited { it.name }}.")
/** Finds the first entry whose [name][INamed.name] [equals] `this` parameter.
* @return `null` if not found. */
fun <T: INamed> findOrNull(options: Sequence<T>): T? = options.firstOrNull { equals(it.name) }
/** Finds the first entry whose [name][INamed.name] [equals] `this` parameter.
* @throws ConsoleErrorException if not found. */
inline fun <reified T: INamed> find(options: Sequence<T>): T = find(options.asIterable())
//endregion
companion object {
val empty = CliInput("")
//////////////////////////////////////////////////////////////// region Private Helpers
private fun String.hasLeadingQuote() = startsWith('\"')
private fun String.toDashedRepresentation() = removeOuterQuotes().lowercase().replace(" ","-")
private fun String.removeOuterQuotes() = removePrefix("\"").removeSuffix("\"")
/**
* Parses input for storage as [content] with [Method.Quoted].
* @return Input without any surrounding quotes, no repeated whitespace, and the first character titlecased
*/
private fun String.toQuotedRepresentation(): String {
// Can be done with String functions, but this might be faster
val sb = StringBuilder(length)
val start = indexOfFirst(::charIsNotAQuote).coerceAtLeast(0)
val end = indexOfLast(::charIsNotAQuote) + 1
if (end > start) {
sb.append(get(start).titlecaseChar())
if (end > start + 1)
sb.append(substring(start + 1, end).replace(repeatedWhiteSpaceRegex, ""))
}
return sb.toString()
}
private fun charIsNotAQuote(char: Char) = char != '\"'
private val repeatedWhiteSpaceRegex = Regex("""(?<=\s)\s+""")
// Read: Any whitespace sequence preceded by a whitespace (using positive lookbehind, so the preceding whitespace is not part of the match)
@Suppress("RegExpRepeatedSpace", "RegExpUnnecessaryNonCapturingGroup")
private val splitStringRegex = Regex("""
"[^"]+(?:"|$) # A quoted phrase, but the closing quote is optional at the end of the string
| # OR
\S+ # consecutive non-whitespace
| # OR
(?:(?<=\s)$) # a terminal empty string if preceded by whitespace
""", RegexOption.COMMENTS)
// (the unfinished quoted string or empty token at the end are allowed to support autocomplete)
//endregion
fun String.splitToCliInput() =
splitStringRegex.findAll(this)
.map { CliInput(it.value) }
.toList()
fun CliInput?.orEmpty() = this ?: empty
@Suppress("USELESS_CAST") // not useless, filterIsInstance annotates `T` with `@NoInfer`
/** Finds ruleset objects of type [T] whose name matches parameter [param].
* Receiver [DevConsolePopup] is used to access the ruleset.
* - Note this has a level of redundancy with the [CliInput.find] and [CliInput.findOrNull] family of methods.
* (Actually this delegates to [CliInput.findOrNull] but passes `allRulesetObjects().filterIsInstance<T>` as collection to search).
* `param.findOrNull(console.gameInfo.ruleset.<collectionOfT>)` is more efficient than this but more verbose to write, and this can find subclasses of <T>.
*/
inline fun <reified T: IRulesetObject> DevConsolePopup.findCliInput(param: CliInput) =
param.findOrNull(gameInfo.ruleset.allRulesetObjects().filterIsInstance<T>() as Sequence<T>)
/** For use in overrides of [ConsoleCommand.autocomplete]:
* Gets the string to replace the last parameter [lastWord] with from [allOptions].
*/
internal fun getAutocompleteString(lastWord: CliInput, allOptions: Iterable<CliInput>, console: DevConsolePopup): String? {
console.showResponse(null, Color.WHITE)
val matchingOptions = allOptions.filter { it.startsWith(lastWord) }
if (matchingOptions.isEmpty()) return null
if (matchingOptions.size == 1)
return matchingOptions.first().getAutocompleteString(lastWord.method, toAppend = " ")
val showMethod = lastWord.method or matchingOptions.first().method
val message = matchingOptions.asSequence()
.map { it.toMethod(showMethod) }
.asIterable()
.joinToStringLimited(prefix = "Matching completions: ")
console.showResponse(message, Color.LIME.lerp(Color.OLIVE.cpy(), 0.5f))
val firstOption = matchingOptions.first()
for ((index, char) in firstOption.getDashedRepresentation().withIndex()) {
if (matchingOptions.any { it.getDashedRepresentation().lastIndex < index } ||
matchingOptions.any { it.getDashedRepresentation()[index] != char })
return firstOption.getAutocompleteString(lastWord.method, index)
}
return firstOption.getAutocompleteString(lastWord.method) // don't add space, e.g. found drill-i and user might want drill-ii
}
@JvmName("getAutocompleteStringFromStrings")
internal fun getAutocompleteString(lastWord: CliInput, allOptions: Iterable<String>, console: DevConsolePopup): String? =
getAutocompleteString(lastWord, allOptions.map { CliInput(it) }, console)
private fun <T> Iterable<T>.joinToStringLimited(separator: String = ", ", prefix: String = "", postfix: String = "", limit: Int = 42, transform: ((T)->String)? = null)
= joinToString(separator, prefix, postfix, limit, "... (${count() - limit} not shown)", transform)
}
}

View File

@ -1,13 +1,14 @@
package com.unciv.ui.screens.devconsole
import com.unciv.models.ruleset.Building
import com.unciv.ui.screens.devconsole.CliInput.Companion.findCliInput
class ConsoleCityCommands : ConsoleCommandNode {
internal class ConsoleCityCommands : ConsoleCommandNode {
override val subcommands = hashMapOf<String, ConsoleCommand>(
"checkfilter" to ConsoleAction("city checkfilter <cityFilter>") { console, params ->
val city = console.getSelectedCity()
DevConsoleResponse.hint(city.matchesFilter(params[0]).toString())
DevConsoleResponse.hint(city.matchesFilter(params[0].toString()).toString())
},
"add" to ConsoleAction("city add <civName>") { console, params ->
@ -27,7 +28,7 @@ class ConsoleCityCommands : ConsoleCommandNode {
"setpop" to ConsoleAction("city setpop <amount>") { console, params ->
val city = console.getSelectedCity()
val newPop = console.getInt(params[0])
val newPop = params[0].toInt()
if (newPop < 1) throw ConsoleErrorException("Population must be at least 1")
city.population.setPopulation(newPop)
DevConsoleResponse.OK
@ -35,7 +36,7 @@ class ConsoleCityCommands : ConsoleCommandNode {
"setname" to ConsoleAction("city setname <\"name\">") { console, params ->
val city = console.getSelectedCity()
city.name = params[0]
city.name = params[0].toString()
DevConsoleResponse.OK
},
@ -44,7 +45,7 @@ class ConsoleCityCommands : ConsoleCommandNode {
val city = console.getCity(params[0])
if (selectedTile.neighbors.none { it.getCity() == city })
throw ConsoleErrorException("Tile is not adjacent to any tile already owned by the city")
if (selectedTile.isCityCenter()) throw ConsoleErrorException("Cannot tranfer city center")
if (selectedTile.isCityCenter()) throw ConsoleErrorException("Cannot transfer city center")
city.expansion.takeOwnership(selectedTile)
DevConsoleResponse.OK
},
@ -58,9 +59,9 @@ class ConsoleCityCommands : ConsoleCommandNode {
"religion" to ConsoleAction("city religion <religionName> <±pressure>") { console, params ->
val city = console.getSelectedCity()
val religion = city.civ.gameInfo.religions.keys.findCliInput(params[0])
val religion = params[0].findOrNull(console.gameInfo.religions.keys)
?: throw ConsoleErrorException("'${params[0]}' is not a known religion")
val pressure = console.getInt(params[1])
val pressure = params[1].toInt()
city.religion.addPressure(religion, pressure.coerceAtLeast(-city.religion.getPressures()[religion]))
city.religion.updatePressureOnPopulationChange(0)
DevConsoleResponse.OK
@ -69,9 +70,7 @@ class ConsoleCityCommands : ConsoleCommandNode {
"sethealth" to ConsoleAction("city sethealth [amount]") { console, params ->
val city = console.getSelectedCity()
val maxHealth = city.getMaxHealth()
val health = params.firstOrNull()?.run {
toIntOrNull() ?: throw ConsoleErrorException("Invalid number")
} ?: maxHealth
val health = params.firstOrNull()?.toInt() ?: maxHealth
if (health !in 1..maxHealth) throw ConsoleErrorException("Number out of range")
city.health = health
DevConsoleResponse.OK

View File

@ -2,23 +2,19 @@ package com.unciv.ui.screens.devconsole
import com.unciv.logic.civilization.PlayerType
import com.unciv.models.ruleset.Policy
import com.unciv.models.ruleset.PolicyBranch
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.models.stats.Stat
import com.unciv.ui.screens.devconsole.CliInput.Companion.findCliInput
class ConsoleCivCommands : ConsoleCommandNode {
internal class ConsoleCivCommands : ConsoleCommandNode {
override val subcommands = hashMapOf<String, ConsoleCommand>(
"addstat" to ConsoleAction("civ addstat <stat> <amount> [civ]") { console, params ->
val stat = Stat.safeValueOf(params[0].replaceFirstChar(Char::titlecase))
?: throw ConsoleErrorException("\"${params[0]}\" is not an acceptable Stat")
val stat = params[0].toStat()
if (stat !in Stat.statsWithCivWideField)
throw ConsoleErrorException("$stat is not civ-wide")
val amount = console.getInt(params[1])
val civ = if (params.size == 2) console.screen.selectedCiv
else console.getCivByName(params[2])
val amount = params[1].toInt()
val civ = console.getCivByNameOrSelected(params.getOrNull(2))
civ.addStat(stat, amount)
DevConsoleResponse.OK
@ -26,30 +22,20 @@ class ConsoleCivCommands : ConsoleCommandNode {
"setplayertype" to ConsoleAction("civ setplayertype <civName> <ai/human>") { console, params ->
val civ = console.getCivByName(params[0])
val playerType = PlayerType.values().firstOrNull { it.name.lowercase() == params[1].lowercase() }
?: throw ConsoleErrorException("Invalid player type, valid options are 'ai' or 'human'")
civ.playerType = playerType
civ.playerType = params[1].enumValue<PlayerType>()
DevConsoleResponse.OK
},
"revealmap" to ConsoleAction("civ revealmap <civName>") { console, params ->
val civ = console.getCivByName(params[0])
"revealmap" to ConsoleAction("civ revealmap [civName]") { console, params ->
val civ = console.getCivByNameOrSelected(params.getOrNull(0))
civ.gameInfo.tileMap.values.asSequence()
.forEach { it.setExplored(civ, true) }
DevConsoleResponse.OK
},
"activatetrigger" to ConsoleAction("civ activatetrigger <civName> <\"trigger\">") { console, params ->
val civ = console.getCivByName(params[0])
val unique = Unique(params[1])
if (unique.type == null) throw ConsoleErrorException("Unrecognized trigger")
val tile = console.screen.mapHolder.selectedTile
val city = tile?.getCity()
UniqueTriggerActivation.triggerUnique(unique, civ, city, tile = tile)
DevConsoleResponse.OK
},
"activatetrigger" to ConsoleTriggerAction("civ"),
"addpolicy" to ConsoleAction("civ addpolicy <civName> <policyName>") { console, params ->
"addpolicy" to ConsoleAction("civ addpolicy <civName> <policyName>") { console, params ->
val civ = console.getCivByName(params[0])
val policy = console.findCliInput<Policy>(params[1]) // yes this also finds PolicyBranch instances
?: throw ConsoleErrorException("Unrecognized policy")
@ -62,7 +48,7 @@ class ConsoleCivCommands : ConsoleCommandNode {
}
},
"removepolicy" to ConsoleAction("civ removepolicy <civName> <policyName>") { console, params ->
"removepolicy" to ConsoleAction("civ removepolicy <civName> <policyName>") { console, params ->
val civ = console.getCivByName(params[0])
val policy = console.findCliInput<Policy>(params[1])
?: throw ConsoleErrorException("Unrecognized policy")

View File

@ -3,6 +3,8 @@ package com.unciv.ui.screens.devconsole
import com.unciv.logic.GameInfo
import com.unciv.logic.map.mapgenerator.RiverGenerator
import com.unciv.models.ruleset.tile.TerrainType
import com.unciv.models.ruleset.unique.UniqueTarget
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.stats.Stat
@Suppress("EnumEntryName", "unused")
@ -14,7 +16,8 @@ import com.unciv.models.stats.Stat
* - Supports multi-type parameters via [Companion.multiOptions]
*/
internal enum class ConsoleParameterType(
private val getOptions: GameInfo.() -> Iterable<String>
private val getOptions: GameInfo.() -> Iterable<String>,
val preferquoted: Boolean = false
) {
none( { emptyList() } ),
civName( { civilizations.map { it.civName } } ),
@ -30,13 +33,17 @@ internal enum class ConsoleParameterType(
direction( { RiverGenerator.RiverDirections.names } ),
policyName( { ruleset.policyBranches.keys + ruleset.policies.keys } ),
cityName( { civilizations.flatMap { civ -> civ.cities.map { it.name } } } ),
triggeredUniqueTemplate( { UniqueType.values().filter { it.canAcceptUniqueTarget(UniqueTarget.Triggerable) }.map { it.text } }, preferquoted = true ),
;
private fun getOptions(console: DevConsolePopup) = console.gameInfo.getOptions()
companion object {
fun safeValueOf(name: String): ConsoleParameterType = values().firstOrNull { it.name == name } ?: none
fun getOptions(name: String, console: DevConsolePopup) = safeValueOf(name).getOptions(console)
fun getOptions(name: String, console: DevConsolePopup) = safeValueOf(name).let { type ->
if (type.preferquoted) type.getOptions(console).map { CliInput(it, CliInput.Method.Quoted) }
else type.getOptions(console).map { CliInput(it) }
}
fun multiOptions(name: String, console: DevConsolePopup) = name.split('|').flatMap { getOptions(it, console) }
}
}

View File

@ -2,7 +2,6 @@ package com.unciv.ui.screens.devconsole
import com.unciv.Constants
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.LocationAction
import com.unciv.logic.civilization.Notification
import com.unciv.logic.civilization.NotificationCategory
@ -14,7 +13,7 @@ import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.tile.Terrain
import com.unciv.models.ruleset.tile.TerrainType
class ConsoleTileCommands: ConsoleCommandNode {
internal class ConsoleTileCommands: ConsoleCommandNode {
// Note: We *don't* call `TileInfoNormalizer.normalizeToRuleset(selectedTile, console.gameInfo.ruleset)`
// - we want the console to allow invalid tile configurations.
@ -22,17 +21,13 @@ class ConsoleTileCommands: ConsoleCommandNode {
"checkfilter" to ConsoleAction("tile checkfilter <tileFilter>") { console, params ->
val selectedTile = console.getSelectedTile()
DevConsoleResponse.hint(selectedTile.matchesFilter(params[0]).toString())
DevConsoleResponse.hint(selectedTile.matchesFilter(params[0].toString()).toString())
},
"setimprovement" to ConsoleAction("tile setimprovement <improvementName> [civName]") { console, params ->
val selectedTile = console.getSelectedTile()
val improvement = console.gameInfo.ruleset.tileImprovements.values.findCliInput(params[0])
?: throw ConsoleErrorException("Unknown improvement")
var civ: Civilization? = null
if (params.size == 2) {
civ = console.getCivByName(params[1])
}
val improvement = params[0].find(console.gameInfo.ruleset.tileImprovements.values)
val civ = params.getOrNull(1)?.let { console.getCivByName(it) }
selectedTile.improvementFunctions.changeImprovement(improvement.name, civ)
selectedTile.getCity()?.reassignPopulation()
DevConsoleResponse.OK
@ -78,8 +73,7 @@ class ConsoleTileCommands: ConsoleCommandNode {
"setterrain" to ConsoleAction("tile setterrain <terrainName>") { console, params ->
val selectedTile = console.getSelectedTile()
val terrain = console.gameInfo.ruleset.terrains.values.findCliInput(params[0])
?: throw ConsoleErrorException("Unknown terrain")
val terrain = params[0].find(console.gameInfo.ruleset.terrains.values)
if (terrain.type == TerrainType.NaturalWonder)
setNaturalWonder(selectedTile, terrain)
else
@ -88,8 +82,7 @@ class ConsoleTileCommands: ConsoleCommandNode {
"setresource" to ConsoleAction("tile setresource <resourceName>") { console, params ->
val selectedTile = console.getSelectedTile()
val resource = console.gameInfo.ruleset.tileResources.values.findCliInput(params[0])
?: throw ConsoleErrorException("Unknown resource")
val resource = params[0].find(console.gameInfo.ruleset.tileResources.values)
selectedTile.resource = resource.name
selectedTile.setTerrainTransients()
selectedTile.getCity()?.reassignPopulation()
@ -110,19 +103,7 @@ class ConsoleTileCommands: ConsoleCommandNode {
"setowner" to ConsoleAction("tile setowner [civName|cityName]") { console, params ->
val selectedTile = console.getSelectedTile()
val oldOwner = selectedTile.getCity()
val newOwner: City? =
if (params.isEmpty() || params[0].isEmpty()) null
else {
val param = params[0].toCliInput()
// Look for a city name to assign the Tile to
console.gameInfo.civilizations
.flatMap { civ -> civ.cities }
.firstOrNull { it.name.toCliInput() == param }
// If the user didn't specify a City, they must have given us a Civilization instead -
// copy of TileImprovementFunctions.takeOverTilesAround.fallbackNearestCity
?: console.getCivByName(params[0]) // throws if no match
.cities.minByOrNull { it.getCenterTile().aerialDistanceTo(selectedTile) + (if (it.isBeingRazed) 5 else 0) }
}
val newOwner = getOwnerCity(console, params, selectedTile)
// for simplicity, treat assign to civ without cities same as un-assign
oldOwner?.expansion?.relinquishOwnership(selectedTile) // redundant if new owner is not null, but simpler for un-assign
newOwner?.expansion?.takeOwnership(selectedTile)
@ -132,7 +113,7 @@ class ConsoleTileCommands: ConsoleCommandNode {
"find" to ConsoleAction("tile find <tileFilter>") { console, params ->
val filter = params[0]
val locations = console.gameInfo.tileMap.tileList
.filter { it.matchesFilter(filter) }
.filter { it.matchesFilter(filter.toString()) }
.map { it.position }
if (locations.isEmpty()) DevConsoleResponse.hint("None found")
else {
@ -170,20 +151,19 @@ class ConsoleTileCommands: ConsoleCommandNode {
return DevConsoleResponse.OK
}
private fun getTerrainFeature(console: DevConsolePopup, param: String) =
private fun getTerrainFeature(console: DevConsolePopup, param: CliInput) = param.find(
console.gameInfo.ruleset.terrains.values.asSequence()
.filter { it.type == TerrainType.TerrainFeature }.findCliInput(param)
?: throw ConsoleErrorException("Unknown feature")
.filter { it.type == TerrainType.TerrainFeature }
)
private class ConsoleRiverAction(format: String, newValue: Boolean) : ConsoleAction(
format,
action = { console, params -> action(console, params, newValue) }
) {
companion object {
private fun action(console: DevConsolePopup, params: List<String>, newValue: Boolean): DevConsoleResponse {
private fun action(console: DevConsolePopup, params: List<CliInput>, newValue: Boolean): DevConsoleResponse {
val selectedTile = console.getSelectedTile()
val direction = findCliInput<RiverDirections>(params[0])
?: throw ConsoleErrorException("Unknown direction - use " + RiverDirections.names.joinToString())
val direction = params[0].enumValue<RiverDirections>()
val otherTile = direction.getNeighborTile(selectedTile)
?: throw ConsoleErrorException("tile has no neighbor to the " + direction.name)
if (!otherTile.isLand)
@ -193,4 +173,16 @@ class ConsoleTileCommands: ConsoleCommandNode {
}
}
}
private fun getOwnerCity(console: DevConsolePopup, params: List<CliInput>, selectedTile: Tile): City? {
val param = params.getOrNull(0) ?: return null
if (param.isEmpty()) return null
// Look for a city by name to assign the Tile to
val namedCity = param.findOrNull(console.gameInfo.civilizations.flatMap { civ -> civ.cities })
if (namedCity != null) return namedCity
// If the user didn't specify a City, they must have given us a Civilization instead
val namedCiv = console.getCivByNameOrNull(param)
?: throw ConsoleErrorException("$param is neither a city nor a civilization")
return namedCiv.cities.minByOrNull { it.getCenterTile().aerialDistanceTo(selectedTile) + (if (it.isBeingRazed) 5 else 0) }
}
}

View File

@ -0,0 +1,101 @@
package com.unciv.ui.screens.devconsole
import com.unciv.logic.civilization.Civilization
import com.unciv.models.ruleset.RulesetObject
import com.unciv.models.ruleset.unique.Unique
import com.unciv.models.ruleset.unique.UniqueTarget
import com.unciv.models.ruleset.unique.UniqueTriggerActivation
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.ruleset.validation.UniqueValidator
import com.unciv.models.translations.fillPlaceholders
import com.unciv.models.translations.getPlaceholderText
/**
* Container for console access to [UniqueTriggerActivation.triggerUnique].
*
* @param topLevelCommand For the beginning of the format string. Also used to control syntax checks.
*/
internal class ConsoleTriggerAction(
topLevelCommand: String
) : ConsoleAction("$topLevelCommand activatetrigger <triggeredUnique|triggeredUniqueTemplate> [uniqueParam]...", getAction(topLevelCommand)) {
companion object {
private fun getAction(topLevelCommand: String): (DevConsolePopup, List<CliInput>) -> DevConsoleResponse {
return { console: DevConsolePopup, params: List<CliInput> ->
val paramStack = ArrayDeque(params)
// The city and tile blocks could be written shorter without try-catch, but this way the error message is easily kept in one place
val city = try {
console.getSelectedCity()
} catch (ex: ConsoleErrorException) {
if (topLevelCommand == "city") throw ex
null
}
val unit = try {
console.getSelectedUnit()
} catch (ex: ConsoleErrorException) {
if (topLevelCommand == "unit") throw ex
null
}
val tile = try {
console.getSelectedTile()
} catch (ex: ConsoleErrorException) {
if (topLevelCommand == "tile") throw ex
null
}
val civ = getCiv(console, topLevelCommand, paramStack) ?: city?.civ ?: unit?.civ ?: tile?.getOwner()
?: throw ConsoleErrorException("A trigger command needs a Civilization from some source")
val unique = getUnique(console, paramStack)
if (UniqueTriggerActivation.triggerUnique(unique, civ, city, unit, tile, null, "due to cheating"))
DevConsoleResponse.OK
else DevConsoleResponse.error("The `triggerUnique` call failed")
}
}
private fun getCiv(console: DevConsolePopup, topLevelCommand: String, paramStack: ArrayDeque<CliInput>): Civilization? {
if (topLevelCommand != "civ") return null
// Came from `civ activatetrigger`: We want a civ, but on the command line it should be an optional parameter, defaulting to WorldScreen selected
val name = paramStack.firstOrNull() ?: return null // not having any param needs a throw, but let the unique getter handle that
val civ = console.getCivByNameOrNull(name) ?: return console.screen.selectedCiv
// name was good - remove from deque
paramStack.removeFirst()
return civ
}
private fun getUnique(console: DevConsolePopup, paramStack: ArrayDeque<CliInput>): Unique {
var uniqueText = paramStack.removeFirstOrNull()?.toMethod(CliInput.Method.Quoted)
?: throw ConsoleErrorException("Parameter triggeredUnique missing")
val uniqueType = getUniqueType(uniqueText)
if (paramStack.isNotEmpty() && uniqueText.equals(uniqueType.text)) {
// Simplification: You either specify a fully formatted Unique as one parameter or the default text and a full set of replacements
val params = paramStack.map { it.originalUnquoted() }.toTypedArray()
uniqueText = CliInput(uniqueType.placeholderText.fillPlaceholders(*params), CliInput.Method.Quoted)
}
val unique = Unique(uniqueText.content, UniqueTarget.Triggerable, "DevConsole")
val validator = UniqueValidator(console.gameInfo.ruleset)
val errors = validator.checkUnique(unique, false, ConsoleRulesetObject(), true)
if (errors.isNotOK())
throw ConsoleErrorException(errors.getErrorText(true))
return unique
}
private fun getUniqueType(param: CliInput): UniqueType {
val filterText = CliInput(param.content.getPlaceholderText(), param.method)
val uniqueTypes = UniqueType.values().asSequence()
.filter { CliInput(it.placeholderText, param.method) == filterText }
.take(4).toList()
if (uniqueTypes.isEmpty())
throw ConsoleErrorException("`$param` not found in UniqueTypes")
if (uniqueTypes.size > 1)
throw ConsoleErrorException("`$param` has ambiguous UniqueType: ${uniqueTypes.joinToString(limit = 3) { it.text }}?")
val uniqueType = uniqueTypes.first()
if (uniqueType.canAcceptUniqueTarget(UniqueTarget.Triggerable))
return uniqueType
throw ConsoleErrorException("`$param` is not a Triggerable")
}
private class ConsoleRulesetObject : RulesetObject() {
override var name = "DevConsole"
override fun getUniqueTarget() = UniqueTarget.Triggerable
override fun makeLink() = ""
}
}
}

View File

@ -1,18 +1,20 @@
package com.unciv.ui.screens.devconsole
class ConsoleUnitCommands : ConsoleCommandNode {
import com.unciv.ui.screens.devconsole.CliInput.Companion.getAutocompleteString
import com.unciv.ui.screens.devconsole.CliInput.Companion.orEmpty
internal class ConsoleUnitCommands : ConsoleCommandNode {
override val subcommands = hashMapOf<String, ConsoleCommand>(
"checkfilter" to ConsoleAction("unit checkfilter <unitFilter>") { console, params ->
val unit = console.getSelectedUnit()
DevConsoleResponse.hint(unit.matchesFilter(params[0]).toString())
DevConsoleResponse.hint(unit.matchesFilter(params[0].toString()).toString())
},
"add" to ConsoleAction("unit add <civName> <unitName>") { console, params ->
val selectedTile = console.getSelectedTile()
val civ = console.getCivByName(params[0])
val baseUnit = console.gameInfo.ruleset.units.values.findCliInput(params[1])
?: throw ConsoleErrorException("Unknown unit")
val baseUnit = params[1].find(console.gameInfo.ruleset.units.values)
civ.units.placeUnitNearTile(selectedTile.position, baseUnit)
DevConsoleResponse.OK
},
@ -25,12 +27,11 @@ class ConsoleUnitCommands : ConsoleCommandNode {
"addpromotion" to object : ConsoleAction("unit addpromotion <promotionName>", { console, params ->
val unit = console.getSelectedUnit()
val promotion = console.gameInfo.ruleset.unitPromotions.values.findCliInput(params[0])
?: throw ConsoleErrorException("Unknown promotion")
val promotion = params[0].find(console.gameInfo.ruleset.unitPromotions.values)
unit.promotions.addPromotion(promotion.name, true)
DevConsoleResponse.OK
}) {
override fun autocomplete(console: DevConsolePopup, params: List<String>): String {
override fun autocomplete(console: DevConsolePopup, params: List<CliInput>): String? {
// Note: filtering by unit.type.name in promotion.unitTypes sounds good (No [Zero]-Ability on an Archer),
// but would also prevent promotions that can be legally obtained like Morale and Rejuvenation
val promotions = console.getSelectedUnit().promotions.promotions
@ -43,7 +44,7 @@ class ConsoleUnitCommands : ConsoleCommandNode {
"removepromotion" to object : ConsoleAction("unit removepromotion <promotionName>", { console, params ->
val unit = console.getSelectedUnit()
val promotion = unit.promotions.getPromotions().findCliInput(params[0])
val promotion = params[0].findOrNull(unit.promotions.getPromotions())
?: throw ConsoleErrorException("Promotion not found on unit")
// No such action in-game so we need to manually update
unit.promotions.promotions.remove(promotion.name)
@ -51,25 +52,21 @@ class ConsoleUnitCommands : ConsoleCommandNode {
unit.updateVisibleTiles()
DevConsoleResponse.OK
}) {
override fun autocomplete(console: DevConsolePopup, params: List<String>) =
override fun autocomplete(console: DevConsolePopup, params: List<CliInput>) =
getAutocompleteString(params.lastOrNull().orEmpty(), console.getSelectedUnit().promotions.promotions, console)
},
"setmovement" to ConsoleAction("unit setmovement [amount]") { console, params ->
// Note amount defaults to maxMovement, but is not limited by it - it's an arbitrary choice to allow that
val unit = console.getSelectedUnit()
val movement = params.firstOrNull()?.run {
toFloatOrNull() ?: throw ConsoleErrorException("Invalid number")
} ?: unit.getMaxMovement().toFloat()
val movement = params.firstOrNull()?.toFloat() ?: unit.getMaxMovement().toFloat()
if (movement < 0f) throw ConsoleErrorException("Number out of range")
unit.currentMovement = movement
DevConsoleResponse.OK
},
"sethealth" to ConsoleAction("unit sethealth [amount]") { console, params ->
val health = params.firstOrNull()?.run {
toIntOrNull() ?: throw ConsoleErrorException("Invalid number")
} ?: 100
val health = params.firstOrNull()?.toInt() ?: 100
if (health !in 1..100) throw ConsoleErrorException("Number out of range")
val unit = console.getSelectedUnit()
unit.health = health

View File

@ -1,67 +1,28 @@
package com.unciv.ui.screens.devconsole
import com.badlogic.gdx.graphics.Color
import com.unciv.logic.map.mapgenerator.RiverGenerator
import com.unciv.models.ruleset.IRulesetObject
import com.unciv.models.ruleset.tile.TerrainType
import com.unciv.models.stats.Stat
import com.unciv.ui.screens.devconsole.CliInput.Companion.getAutocompleteString
import com.unciv.ui.screens.devconsole.CliInput.Companion.orEmpty
internal fun String.toCliInput() = this.lowercase().replace(" ","-")
internal interface ConsoleCommand {
fun handle(console: DevConsolePopup, params: List<CliInput>): DevConsoleResponse
internal fun Iterable<String>.findCliInput(param: String): String? {
val paramCli = param.toCliInput()
return firstOrNull { it.toCliInput() == paramCli }
}
internal fun <T: IRulesetObject> Iterable<T>.findCliInput(param: String): T? {
val paramCli = param.toCliInput()
return firstOrNull { it.name.toCliInput() == paramCli }
}
internal fun <T: IRulesetObject> Sequence<T>.findCliInput(param: String) = asIterable().findCliInput(param)
internal inline fun <reified T: Enum<T>> findCliInput(param: String): T? {
val paramCli = param.toCliInput()
return enumValues<T>().firstOrNull {
it.name.toCliInput() == paramCli
}
}
@Suppress("USELESS_CAST") // not useless, filterIsInstance annotates `T` with `@NoInfer`
internal inline fun <reified T: IRulesetObject> DevConsolePopup.findCliInput(param: String) =
(gameInfo.ruleset.allRulesetObjects().filterIsInstance<T>() as Sequence<T>).findCliInput(param)
/** Returns the string to *add* to the existing command */
internal fun getAutocompleteString(lastWord: String, allOptions: Iterable<String>, console: DevConsolePopup): String {
console.showResponse(null, Color.WHITE)
val matchingOptions = allOptions.map { it.toCliInput() }.filter { it.startsWith(lastWord.toCliInput()) }
if (matchingOptions.isEmpty()) return ""
if (matchingOptions.size == 1) return matchingOptions.first().drop(lastWord.length) + " "
console.showResponse("Matching completions: " + matchingOptions.joinToString(), Color.LIME.lerp(Color.OLIVE.cpy(), 0.5f))
val firstOption = matchingOptions.first()
for ((index, char) in firstOption.withIndex()) {
if (matchingOptions.any { it.lastIndex < index } ||
matchingOptions.any { it[index] != char })
return firstOption.substring(0, index).drop(lastWord.length)
}
return firstOption.drop(lastWord.length) // don't add space, e.g. found drill-i and user might want drill-ii
}
interface ConsoleCommand {
fun handle(console: DevConsolePopup, params: List<String>): DevConsoleResponse
/** Returns the string to *add* to the existing command.
/** Returns the string to replace the last parameter of the existing command with. `null` means no change due to no options.
* The function should add a space at the end if and only if the "match" is an unambiguous choice!
*/
fun autocomplete(console: DevConsolePopup, params: List<String>): String = ""
fun autocomplete(console: DevConsolePopup, params: List<CliInput>): String? = null
}
class ConsoleHintException(val hint: String) : Exception()
class ConsoleErrorException(val error: String) : Exception()
/** An Exception representing a minor user error in [DevConsolePopup] input. [hint] is user-readable but never translated and should help understanding how to fix the mistake. */
internal class ConsoleHintException(val hint: String) : Exception()
open class ConsoleAction(val format: String, val action: (console: DevConsolePopup, params: List<String>) -> DevConsoleResponse) : ConsoleCommand {
override fun handle(console: DevConsolePopup, params: List<String>): DevConsoleResponse {
/** An Exception representing a user error in [DevConsolePopup] input. [error] is user-readable but never translated. */
internal class ConsoleErrorException(val error: String) : Exception()
internal open class ConsoleAction(
val format: String,
val action: (console: DevConsolePopup, params: List<CliInput>) -> DevConsoleResponse
) : ConsoleCommand {
override fun handle(console: DevConsolePopup, params: List<CliInput>): DevConsoleResponse {
return try {
validateFormat(format, params)
action(console, params)
@ -72,48 +33,55 @@ open class ConsoleAction(val format: String, val action: (console: DevConsolePop
}
}
override fun autocomplete(console: DevConsolePopup, params: List<String>): String {
val formatParams = format.split(" ").drop(2).map {
override fun autocomplete(console: DevConsolePopup, params: List<CliInput>): String? {
val formatParams = format.split(' ').drop(2).map {
it.removeSurrounding("<",">").removeSurrounding("[","]").removeSurrounding("\"")
}
if (formatParams.size < params.size) return ""
// It is possible we're here *with* another format parameter but an *empty* params (e.g. `tile addriver ` and hit tab) -> see else branch
if (formatParams.size < params.size) return null // format has no definition, so there are no options to choose from
// It is possible we're here *with* another format parameter but an *empty* params (e.g. `tile addriver` and hit tab) -> see below
val (formatParam, lastParam) = if (params.lastIndex in formatParams.indices)
formatParams[params.lastIndex] to params.last()
else formatParams.first() to ""
else formatParams.first() to null
val options = ConsoleParameterType.multiOptions(formatParam, console)
return getAutocompleteString(lastParam, options, console)
val result = getAutocompleteString(lastParam.orEmpty(), options, console)
if (lastParam != null || result == null) return result
// we got the situation described above and something to add: The caller will ultimately replace the second subcommand, so add it back
// border case, only happens right after the second token, not after the third: Don't optimize the double split call
return format.split(' ')[1] + " " + result
}
private fun validateFormat(format: String, params: List<String>) {
val allParams = format.split(" ")
private fun validateFormat(format: String, params: List<CliInput>) {
val allParams = format.split(' ')
val requiredParamsAmount = allParams.count { it.startsWith('<') }
val optionalParamsAmount = allParams.count { it.startsWith('[') }
if (params.size < requiredParamsAmount || params.size > requiredParamsAmount + optionalParamsAmount)
val optionalParamsAmount = if (format.endsWith("]...")) 999999 else allParams.count { it.startsWith('[') }
// For this check, ignore an empty token caused by a trailing blank
val paramsSize = if (params.isEmpty()) 0 else if (params.last().isEmpty()) params.size - 1 else params.size
if (paramsSize < requiredParamsAmount || paramsSize > requiredParamsAmount + optionalParamsAmount)
throw ConsoleHintException("Format: $format")
}
}
interface ConsoleCommandNode : ConsoleCommand {
internal interface ConsoleCommandNode : ConsoleCommand {
val subcommands: HashMap<String, ConsoleCommand>
override fun handle(console: DevConsolePopup, params: List<String>): DevConsoleResponse {
override fun handle(console: DevConsolePopup, params: List<CliInput>): DevConsoleResponse {
if (params.isEmpty())
return DevConsoleResponse.hint("Available commands: " + subcommands.keys.joinToString())
val handler = subcommands[params[0]]
val handler = subcommands[params[0].toString()]
?: return DevConsoleResponse.error("Invalid command.\nAvailable commands:" + subcommands.keys.joinToString("") { "\n- $it" })
return handler.handle(console, params.drop(1))
}
override fun autocomplete(console: DevConsolePopup, params: List<String>): String {
override fun autocomplete(console: DevConsolePopup, params: List<CliInput>): String? {
val firstParam = params.firstOrNull().orEmpty()
if (firstParam in subcommands) return subcommands[firstParam]!!.autocomplete(console, params.drop(1))
return getAutocompleteString(firstParam, subcommands.keys, console)
val handler = subcommands[firstParam.toString()]
?: return getAutocompleteString(firstParam, subcommands.keys, console)
return handler.autocomplete(console, params.drop(1))
}
}
class ConsoleCommandRoot : ConsoleCommandNode {
internal class ConsoleCommandRoot : ConsoleCommandNode {
override val subcommands = hashMapOf<String, ConsoleCommand>(
"unit" to ConsoleUnitCommands(),
"city" to ConsoleCityCommands(),

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.actions.Actions
import com.unciv.Constants
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.ui.components.UncivTextField
import com.unciv.ui.components.extensions.toCheckBox
@ -11,6 +12,7 @@ import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.devconsole.CliInput.Companion.splitToCliInput
import com.unciv.ui.screens.worldscreen.WorldScreen
@ -48,8 +50,11 @@ class DevConsolePopup(val screen: WorldScreen) : Popup(screen) {
clickBehindToClose = true
textField.keyShortcuts.add(KeyCharAndCode.TAB) {
val textToAdd = getAutocomplete()
textField.appendText(textToAdd)
getAutocomplete()?.also {
fun String.removeFromEnd(n: Int) = substring(0, (length - n).coerceAtLeast(0))
textField.text = textField.text.removeFromEnd(it.first) + it.second
textField.cursorPosition = Int.MAX_VALUE // because the setText implementation actively resets it after the paste it uses (auto capped at length)
}
}
keyShortcuts.add(Input.Keys.UP) { navigateHistory(-1) }
@ -96,30 +101,36 @@ class DevConsolePopup(val screen: WorldScreen) : Popup(screen) {
responseLabel.style.fontColor = color
}
private val splitStringRegex = Regex("\"([^\"]+)\"|\\S+") // Read: "(phrase)" OR non-whitespace
private fun getParams(text: String): List<String> {
return splitStringRegex.findAll(text).map { it.value.removeSurrounding("\"") }.filter { it.isNotEmpty() }.toList()
}
private fun handleCommand(): DevConsoleResponse {
val params = getParams(textField.text)
val params = textField.text.splitToCliInput()
return commandRoot.handle(this, params)
}
private fun getAutocomplete(): String {
val params = getParams(textField.text)
return commandRoot.autocomplete(this, params)
private fun getAutocomplete(): Pair<Int,String>? {
val params = textField.text.splitToCliInput()
val autoCompleteString = commandRoot.autocomplete(this, params)
?: return null
val replaceLength = params.lastOrNull()?.originalLength() ?: 0
return replaceLength to autoCompleteString
}
internal fun getCivByName(name: String) = gameInfo.civilizations.firstOrNull { it.civName.toCliInput() == name.toCliInput() }
internal fun getCivByName(name: CliInput) =
getCivByNameOrNull(name)
?: throw ConsoleErrorException("Unknown civ: $name")
internal fun getCivByNameOrSelected(name: CliInput?) =
name?.let { getCivByName(it) }
?: screen.selectedCiv
internal fun getCivByNameOrNull(name: CliInput): Civilization? =
gameInfo.civilizations.firstOrNull { name.equals(it.civName) }
internal fun getSelectedTile() = screen.mapHolder.selectedTile ?: throw ConsoleErrorException("Select tile")
internal fun getSelectedTile() = screen.mapHolder.selectedTile
?: throw ConsoleErrorException("Select tile")
/** Gets city by selected tile */
internal fun getSelectedCity() = getSelectedTile().getCity() ?: throw ConsoleErrorException("Select tile belonging to city")
internal fun getSelectedCity() = getSelectedTile().getCity()
?: throw ConsoleErrorException("Select tile belonging to city")
internal fun getCity(cityName: String) = gameInfo.getCities().firstOrNull { it.name.toCliInput() == cityName.toCliInput() }
internal fun getCity(cityName: CliInput) = gameInfo.getCities().firstOrNull { cityName.equals(it.name) }
?: throw ConsoleErrorException("Unknown city: $cityName")
internal fun getSelectedUnit(): MapUnit {
@ -130,7 +141,4 @@ class DevConsolePopup(val screen: WorldScreen) : Popup(screen) {
return if (selectedUnit != null && selectedUnit.getTile() == selectedTile) selectedUnit
else units.first()
}
internal fun getInt(param: String) = param.toIntOrNull() ?: throw ConsoleErrorException("$param is not a valid number")
}

View File

@ -4,7 +4,7 @@ package com.unciv.ui.screens.devconsole
import com.badlogic.gdx.graphics.Color
@Suppress("DataClassPrivateConstructor") // abuser need to find copy() first
data class DevConsoleResponse private constructor (
internal data class DevConsoleResponse private constructor (
val color: Color,
val message: String? = null,
val isOK: Boolean = false