mirror of
https://github.com/yairm210/Unciv.git
synced 2025-09-24 03:53:12 -04:00
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:
parent
a046e43dbf
commit
4e28b9e75c
@ -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
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
277
core/src/com/unciv/ui/screens/devconsole/CliInput.kt
Normal file
277
core/src/com/unciv/ui/screens/devconsole/CliInput.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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) }
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
101
core/src/com/unciv/ui/screens/devconsole/ConsoleTriggerAction.kt
Normal file
101
core/src/com/unciv/ui/screens/devconsole/ConsoleTriggerAction.kt
Normal 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() = ""
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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(),
|
||||
|
@ -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")
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user