diff --git a/core/src/com/unciv/logic/city/City.kt b/core/src/com/unciv/logic/city/City.kt index 1cbec6819c..5ca2b5b6ac 100644 --- a/core/src/com/unciv/logic/city/City.kt +++ b/core/src/com/unciv/logic/city/City.kt @@ -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 by lazy { + val neighboringCities: List 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 diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt index 280f791450..6523ef5926 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt @@ -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) { diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt index f9042e963d..a35f897756 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueType.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueType.kt @@ -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), diff --git a/core/src/com/unciv/ui/components/UncivTextField.kt b/core/src/com/unciv/ui/components/UncivTextField.kt index 867cf383bb..19cb115d95 100644 --- a/core/src/com/unciv/ui/components/UncivTextField.kt +++ b/core/src/com/unciv/ui/components/UncivTextField.kt @@ -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 diff --git a/core/src/com/unciv/ui/screens/devconsole/CliInput.kt b/core/src/com/unciv/ui/screens/devconsole/CliInput.kt new file mode 100644 index 0000000000..5097329c54 --- /dev/null +++ b/core/src/com/unciv/ui/screens/devconsole/CliInput.kt @@ -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 { + 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() ?: 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 > enumValueOrNull(): T? = enumValues().firstOrNull { equals(it.name) } + + /** Finds an enum instance of type [T] whose name [equals] `this` parameter. + * @throws ConsoleErrorException if not found. */ + inline fun > enumValue(): T = enumValueOrNull() + ?: throw ConsoleErrorException("'$this' is not a valid ${T::class.java.simpleName}. Options are: ${enumValues()}.") + + /** Finds the first entry that [equals] `this` parameter. + * @return `null` if not found. */ + fun findOrNull(options: Iterable): 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, 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 findOrNull(options: Iterable): T? = options.firstOrNull { equals(it.name) } + + /** Finds the first entry whose [name][INamed.name] [equals] `this` parameter. + * @throws ConsoleErrorException if not found. */ + inline fun find(options: Iterable): 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 findOrNull(options: Sequence): T? = options.firstOrNull { equals(it.name) } + + /** Finds the first entry whose [name][INamed.name] [equals] `this` parameter. + * @throws ConsoleErrorException if not found. */ + inline fun find(options: Sequence): 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` as collection to search). + * `param.findOrNull(console.gameInfo.ruleset.)` is more efficient than this but more verbose to write, and this can find subclasses of . + */ + inline fun DevConsolePopup.findCliInput(param: CliInput) = + param.findOrNull(gameInfo.ruleset.allRulesetObjects().filterIsInstance() as Sequence) + + /** 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, 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, console: DevConsolePopup): String? = + getAutocompleteString(lastWord, allOptions.map { CliInput(it) }, console) + + private fun Iterable.joinToStringLimited(separator: String = ", ", prefix: String = "", postfix: String = "", limit: Int = 42, transform: ((T)->String)? = null) + = joinToString(separator, prefix, postfix, limit, "... (${count() - limit} not shown)", transform) + } +} diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleCityCommands.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleCityCommands.kt index b5fdc1b551..b9ba202785 100644 --- a/core/src/com/unciv/ui/screens/devconsole/ConsoleCityCommands.kt +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleCityCommands.kt @@ -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( "checkfilter" to ConsoleAction("city checkfilter ") { 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 ") { console, params -> @@ -27,7 +28,7 @@ class ConsoleCityCommands : ConsoleCommandNode { "setpop" to ConsoleAction("city setpop ") { 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 <±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 diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleCivCommands.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleCivCommands.kt index a8b685d70e..a481a97891 100644 --- a/core/src/com/unciv/ui/screens/devconsole/ConsoleCivCommands.kt +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleCivCommands.kt @@ -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( "addstat" to ConsoleAction("civ addstat [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 ") { 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() DevConsoleResponse.OK }, - "revealmap" to ConsoleAction("civ revealmap ") { 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 <\"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 ") { console, params -> + "addpolicy" to ConsoleAction("civ addpolicy ") { console, params -> val civ = console.getCivByName(params[0]) val policy = console.findCliInput(params[1]) // yes this also finds PolicyBranch instances ?: throw ConsoleErrorException("Unrecognized policy") @@ -62,7 +48,7 @@ class ConsoleCivCommands : ConsoleCommandNode { } }, - "removepolicy" to ConsoleAction("civ removepolicy ") { console, params -> + "removepolicy" to ConsoleAction("civ removepolicy ") { console, params -> val civ = console.getCivByName(params[0]) val policy = console.findCliInput(params[1]) ?: throw ConsoleErrorException("Unrecognized policy") diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleParameterTypes.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleParameterType.kt similarity index 76% rename from core/src/com/unciv/ui/screens/devconsole/ConsoleParameterTypes.kt rename to core/src/com/unciv/ui/screens/devconsole/ConsoleParameterType.kt index c181294699..c8201a41b0 100644 --- a/core/src/com/unciv/ui/screens/devconsole/ConsoleParameterTypes.kt +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleParameterType.kt @@ -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 + private val getOptions: GameInfo.() -> Iterable, + 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) } } } diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleTileCommands.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleTileCommands.kt index b70c9a7723..72a22d5bcc 100644 --- a/core/src/com/unciv/ui/screens/devconsole/ConsoleTileCommands.kt +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleTileCommands.kt @@ -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 ") { 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 [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 ") { 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 ") { 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 ") { 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, newValue: Boolean): DevConsoleResponse { + private fun action(console: DevConsolePopup, params: List, newValue: Boolean): DevConsoleResponse { val selectedTile = console.getSelectedTile() - val direction = findCliInput(params[0]) - ?: throw ConsoleErrorException("Unknown direction - use " + RiverDirections.names.joinToString()) + val direction = params[0].enumValue() 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, 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) } + } } diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleTriggerAction.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleTriggerAction.kt new file mode 100644 index 0000000000..99292a6251 --- /dev/null +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleTriggerAction.kt @@ -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 [uniqueParam]...", getAction(topLevelCommand)) { + companion object { + private fun getAction(topLevelCommand: String): (DevConsolePopup, List) -> DevConsoleResponse { + return { console: DevConsolePopup, params: List -> + 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): 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): 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() = "" + } + } +} diff --git a/core/src/com/unciv/ui/screens/devconsole/ConsoleUnitCommands.kt b/core/src/com/unciv/ui/screens/devconsole/ConsoleUnitCommands.kt index a1f42b919b..47771dcafc 100644 --- a/core/src/com/unciv/ui/screens/devconsole/ConsoleUnitCommands.kt +++ b/core/src/com/unciv/ui/screens/devconsole/ConsoleUnitCommands.kt @@ -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( "checkfilter" to ConsoleAction("unit checkfilter ") { 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 ") { 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 ", { 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 { + override fun autocomplete(console: DevConsolePopup, params: List): 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 ", { 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) = + override fun autocomplete(console: DevConsolePopup, params: List) = 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 diff --git a/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt b/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt index c69577c0ed..37172f1072 100644 --- a/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt +++ b/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt @@ -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): DevConsoleResponse -internal fun Iterable.findCliInput(param: String): String? { - val paramCli = param.toCliInput() - return firstOrNull { it.toCliInput() == paramCli } -} -internal fun Iterable.findCliInput(param: String): T? { - val paramCli = param.toCliInput() - return firstOrNull { it.name.toCliInput() == paramCli } -} -internal fun Sequence.findCliInput(param: String) = asIterable().findCliInput(param) - -internal inline fun > findCliInput(param: String): T? { - val paramCli = param.toCliInput() - return enumValues().firstOrNull { - it.name.toCliInput() == paramCli - } -} - -@Suppress("USELESS_CAST") // not useless, filterIsInstance annotates `T` with `@NoInfer` -internal inline fun DevConsolePopup.findCliInput(param: String) = - (gameInfo.ruleset.allRulesetObjects().filterIsInstance() as Sequence).findCliInput(param) - -/** Returns the string to *add* to the existing command */ -internal fun getAutocompleteString(lastWord: String, allOptions: Iterable, 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): 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 = "" + fun autocomplete(console: DevConsolePopup, params: List): 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) -> DevConsoleResponse) : ConsoleCommand { - override fun handle(console: DevConsolePopup, params: List): 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) -> DevConsoleResponse +) : ConsoleCommand { + override fun handle(console: DevConsolePopup, params: List): 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 { - val formatParams = format.split(" ").drop(2).map { + override fun autocomplete(console: DevConsolePopup, params: List): 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) { - val allParams = format.split(" ") + private fun validateFormat(format: String, params: List) { + 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 - override fun handle(console: DevConsolePopup, params: List): DevConsoleResponse { + override fun handle(console: DevConsolePopup, params: List): 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 { + override fun autocomplete(console: DevConsolePopup, params: List): 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( "unit" to ConsoleUnitCommands(), "city" to ConsoleCityCommands(), diff --git a/core/src/com/unciv/ui/screens/devconsole/DevConsolePopup.kt b/core/src/com/unciv/ui/screens/devconsole/DevConsolePopup.kt index 5942af4b34..474b357092 100644 --- a/core/src/com/unciv/ui/screens/devconsole/DevConsolePopup.kt +++ b/core/src/com/unciv/ui/screens/devconsole/DevConsolePopup.kt @@ -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 { - 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? { + 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") - } diff --git a/core/src/com/unciv/ui/screens/devconsole/DevConsoleResponse.kt b/core/src/com/unciv/ui/screens/devconsole/DevConsoleResponse.kt index efd9fb19df..9828ab7b62 100644 --- a/core/src/com/unciv/ui/screens/devconsole/DevConsoleResponse.kt +++ b/core/src/com/unciv/ui/screens/devconsole/DevConsoleResponse.kt @@ -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