Implementation of conditionals, but better than before (#5187)

* Implementation of conditionals, but better than before

* Updated the unique while I was at it

* Fixed bug where conditionals would never apply

* Capitalization

* Minor code cleaning

* Better documentation & variable names

* Changed translation strategy

* Added missing import?
This commit is contained in:
Xander Lenstra
2021-09-18 22:07:53 +02:00
committed by GitHub
parent 8cff3fda49
commit 01bfd17594
9 changed files with 309 additions and 68 deletions

View File

@ -24,7 +24,7 @@
{
"name": "Fertility Rites",
"type": "Pantheon",
"uniques": ["+[10]% Growth [in this city]"]
"uniques": ["[+10]% growth [in this city]"]
// Preferably I would not have a cityFilter here, but doing so requires no additional implementation
},
{
@ -193,7 +193,7 @@
{
"name": "Swords into Ploughshares",
"type": "Follower",
"uniques": ["[+15]% growth [in this city] when not at war"]
"uniques": ["[+15]% growth [in this city] <when not at war>"]
},
///////////////////////////////////////// Founder beliefs //////////////////////////////////////////

View File

@ -24,7 +24,7 @@
},
{
"name": "Landed Elite",
"uniques": ["+[10]% growth [in capital]", "[+2 Food] [in capital]"],
"uniques": ["[+10]% growth [in capital]", "[+2 Food] [in capital]"],
"requires": ["Legalism"],
"row": 2,
"column": 2
@ -38,7 +38,7 @@
},
{
"name": "Tradition Complete",
"uniques": ["+[15]% growth [in all cities]","Provides a [Aqueduct] in your first [4] cities for free"]
"uniques": ["[+15]% growth [in all cities]","Provides a [Aqueduct] in your first [4] cities for free"]
}
]
},

View File

@ -6761,3 +6761,6 @@ Often this results in the city immediately converting to their religion =
# Requires translation!
Additionally, when an inquisitor is stationed in or directly next to a city center, units of other religions cannot spread their faith there, though natural spread is uneffected. =
" " = " "
ConditionalsPlacement = after
<when at war> <when not at war> = <when at war> <when not at war>

View File

@ -1,3 +1,18 @@
# Language settings
# Equivalent of a space in your language
# If your language doesn't use spaces, just add "" as a translation, otherwise " "
" " =
# If the first word in a sentence starts with a capital in your language,
# put the english word 'true' behind the '=', otherwise 'false'.
# Don't translate these words to your language, only put 'true' or 'false'.
StartWithCapitalLetter =
# Starting from here normal translations start, as written on
# https://github.com/yairm210/Unciv/wiki/Translating
# Tutorial tasks
@ -795,6 +810,7 @@ Stacked with [unitType] =
The following improvements [stats]: =
The following improvements on [tileType] tiles [stats]: =
# Unit actions
Hurry Research =
Conduct Trade Mission =
@ -811,6 +827,9 @@ Your citizens have been happy with your rule for so long that the empire enters
You have entered the [newEra]! =
[civName] has entered the [eraName]! =
[policyBranch] policy branch unlocked! =
# Overview screens
Overview =
Total =
Stats =
@ -818,36 +837,6 @@ Policies =
Base happiness =
Occupied City =
Buildings =
# terrainFilters (so for uniques like: "[stats] from [terrainFilter] tiles")
All =
Water =
Land =
Coastal =
River =
Open terrain =
Rough terrain =
Foreign Land =
Foreign =
Friendly Land =
Friendly =
Water resource =
Bonus resource =
Luxury resource =
Strategic resource =
Fresh water =
non-fresh water =
Natural Wonder =
# improvementFilters
All =
All Road =
Great Improvement =
Great =
Wonders =
Base values =
Bonuses =
@ -1099,6 +1088,34 @@ Click an icon to see the stats of this religion =
Impassable =
Rare feature =
# terrainFilters (so for uniques like: "[stats] from [terrainFilter] tiles")
All =
Water =
Land =
Coastal =
River =
Open terrain =
Rough terrain =
Foreign Land =
Foreign =
Friendly Land =
Friendly =
Water resource =
Bonus resource =
Luxury resource =
Strategic resource =
Fresh water =
non-fresh water =
Natural Wonder =
# improvementFilters
All =
All Road =
Great Improvement =
Great =
# Resources
Bison =
@ -1230,6 +1247,17 @@ Date ↓ =
Stars ↓ =
Status ↓ =
# City filters
in this city =
in all cities =
in all coastal cities =
in capital =
in all non-occupied cities =
in all cities with a world wonder =
in all cities connected to capital =
in all cities with a garrison =
# Uniques that are relevant to more than one type of game object
[stats] from every [param] =
@ -1246,16 +1274,6 @@ Cannot be built on [tileFilter] tiles =
Does not need removal of [feature] =
Gain a free [building] [cityFilter] =
# City filters
in this city =
in all cities =
in all coastal cities =
in capital =
in all non-occupied cities =
in all cities with a world wonder =
in all cities connected to capital =
in all cities with a garrison =
# Uniques not found in JSON files
Only available after [] turns =
@ -1263,3 +1281,44 @@ This Unit upgrades for free =
[stats] when a city adopts this religion for the first time =
Never destroyed when the city is captured =
Invisible to others =
# Conditionals
# These are optional parts that can be added to uniques to allow them only to function in some cases,
# denoted by placing them between <> brackets. An example would be: "[amount]% Strength <when at war>".
# In this case "<when at war>" is a conditional.
when not at war =
when at war =
# In English we just paste all these conditionals at the end of each unique, but in your language that
# may not turn into valid sentences. Therefore we have the following two translations to determine
# where they should go.
# The first determines whether the conditionals should be placed before or after the base unique.
# It should be translated with only the untranslated english word 'before' or 'after', without the quotes.
# Example: In the unique "+20% Strength <for [unitFilter] units>", should the <for [unitFilter] units>
# be translated before or after the "+20% Strength"?
ConditionalsPlacement =
# The second determines the exact ordering of all conditionals that are to be translated.
# ALL conditionals that exist will be part of this line, and they may be moved around and rearranged as you please.
# However, you should not translate the parts between the brackets, only move them around so that when
# translated in your language the sentence sounds natural.
#
# Note that every time a new conditional is added, it will be added below and this line will have to be retranslated.
# As this will happen quite a lot in the near future, you may want to wait with translating this
# until most conditionals have been added.
#
# Example: "+20% Strength <for [unitFilter] units> <when attacking> <vs [unitFilter] units> <in [tileFilter] tiles> <during the [eraName]>
# In what order should these conditionals between <> be translated?
# Note that this example currently doesn't make sense yet, as those conditionals do not exist, but they will in the future.
<when at war> <when not at war> =
# AUTOMATICALLY GENERATED TRANSLATABLE STRINGS

View File

@ -176,13 +176,21 @@ class CityStats(val cityInfo: CityInfo) {
private fun getGrowthBonusFromPoliciesAndWonders(): Float {
var bonus = 0f
// "+[amount]% growth [cityFilter]"
for (unique in cityInfo.getMatchingUniques("+[]% growth []"))
// "[amount]% growth [cityFilter]"
for (unique in cityInfo.getMatchingUniques("[]% growth []")) {
if (!unique.conditionalsApply(cityInfo.civInfo)) continue
if (cityInfo.matchesFilter(unique.params[1]))
bonus += unique.params[0].toFloat()
for (unique in cityInfo.getMatchingUniques("+[]% growth [] when not at war"))
if (cityInfo.matchesFilter(unique.params[1]) && !cityInfo.civInfo.isAtWar())
bonus += unique.params[0].toFloat()
}
// Deprecated since 3.16.14
for (unique in cityInfo.getMatchingUniques("+[]% growth []")) {
if (cityInfo.matchesFilter(unique.params[1]))
bonus += unique.params[0].toFloat()
}
for (unique in cityInfo.getMatchingUniques("+[]% growth [] when not at war"))
if (cityInfo.matchesFilter(unique.params[1]) && !cityInfo.civInfo.isAtWar())
bonus += unique.params[0].toFloat()
//
return bonus / 100
}

View File

@ -1,29 +1,53 @@
package com.unciv.models.ruleset
import com.unciv.models.stats.Stats
import com.unciv.models.translations.getPlaceholderParameters
import com.unciv.models.translations.getPlaceholderText
import com.unciv.models.translations.*
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.ui.worldscreen.unit.UnitActions
import kotlin.random.Random
class Unique(val text:String) {
/** This is so the heavy regex-based parsing is only activated once per unique, instead of every time it's called
* - for instance, in the city screen, we call every tile unique for every tile, which can lead to ANRs */
val placeholderText = text.getPlaceholderText()
val params = text.getPlaceholderParameters()
val type = UniqueType.values().firstOrNull { it.placeholderText == placeholderText }
/** This is so the heavy regex-based parsing is only activated once per unique, instead of every time it's called
* - for instance, in the city screen, we call every tile unique for every tile, which can lead to ANRs */
val stats: Stats by lazy {
val firstStatParam = params.firstOrNull { Stats.isStats(it) }
if (firstStatParam == null) Stats() // So badly-defined stats don't crash the entire game
else Stats.parse(firstStatParam)
}
val conditionals: List<Unique> = text.getConditionals()
fun isOfType(uniqueType: UniqueType) = uniqueType == type
/** We can't save compliance errors in the unique, since it's ruleset-dependant */
fun matches(uniqueType: UniqueType, ruleset: Ruleset) = isOfType(uniqueType)
&& uniqueType.getComplianceErrors(this, ruleset).isEmpty()
&& uniqueType.getComplianceErrors(this, ruleset).isEmpty()
// This function will get LARGE, as it will basically check for all conditionals if they apply
// This will require a lot of parameters to be passed (attacking unit, tile, defending unit, civInfo, cityInfo, ...)
// I'm open for better ideas, but this was the first thing that I could think of that would
// work in all cases.
fun conditionalsApply(civInfo: CivilizationInfo? = null): Boolean {
for (condition in conditionals) {
if (!conditionalApplies(condition, civInfo)) return false
}
return true
}
private fun conditionalApplies(
condition: Unique,
civInfo: CivilizationInfo? = null
): Boolean {
return when (condition.placeholderText) {
"when not at war" -> civInfo?.isAtWar() == false
"when at war" -> civInfo?.isAtWar() == true
else -> false
}
}
}
@ -42,4 +66,4 @@ class UniqueMap:HashMap<String, ArrayList<Unique>>() {
fun getUniques(uniqueType: UniqueType) = getUniques(uniqueType.placeholderText)
fun getAllUniques() = this.asSequence().flatMap { it.value.asSequence() }
}
}

View File

@ -109,9 +109,9 @@ object TranslationFileWriter {
}
val translationKey = line.split(" = ")[0].replace("\\n", "\n")
val hashMapKey = if (translationKey.contains('['))
translationKey.replace(squareBraceRegex, "[]")
else translationKey
val hashMapKey = translationKey
.replace(pointyBraceRegex, "")
.replace(squareBraceRegex, "[]")
if (existingTranslationKeys.contains(hashMapKey)) continue // don't add it twice
existingTranslationKeys.add(hashMapKey)

View File

@ -2,6 +2,7 @@ package com.unciv.models.translations
import com.badlogic.gdx.Gdx
import com.unciv.UncivGame
import com.unciv.models.ruleset.Unique
import com.unciv.models.stats.Stats
import java.util.*
import kotlin.collections.HashMap
@ -27,7 +28,7 @@ import kotlin.collections.LinkedHashSet
* @see String.tr for more explanations (below)
*/
class Translations : LinkedHashMap<String, TranslationEntry>(){
var percentCompleteOfLanguages = HashMap<String,Int>()
.apply { put("English",100) } // So even if we don't manage to load the percentages, we can still pass the language screen
@ -185,6 +186,36 @@ class Translations : LinkedHashMap<String, TranslationEntry>(){
val translationFilesTime = System.currentTimeMillis() - startTime
println("Loading percent complete of languages - ${translationFilesTime}ms")
}
fun getConditionalOrder(language: String): String {
return getText(englishConditionalOrderingString, language, null)
}
fun placeConditionalsAfterUnique(language: String): Boolean {
if (get(conditionalUniqueOrderString, language, null)?.get(language) == "before")
return false
return true
}
/** Returns the equivalent of a space in the given language
* Defaults to a space if no translation is provided
*/
fun getSpaceEquivalent(language: String): String {
val translation = getText("\" \"", language, null)
return translation.substring(1, translation.length-1)
}
fun shouldCapitalize(language: String): Boolean {
return get(shouldCapitalizeString, language, null)?.get(language)?.toBoolean() ?: true
}
companion object {
// Whenever this string is changed, it should also be changed in the translation files!
// It is mostly used as the template for translating the order of conditionals
const val englishConditionalOrderingString = "<when at war> <when not at war>"
const val conditionalUniqueOrderString = "ConditionalsPlacement"
const val shouldCapitalizeString = "StartWithCapitalLetter"
}
}
@ -202,6 +233,10 @@ val eitherSquareBraceRegex = Regex("""\[|\]""")
// Analogous as above: Expect a {} pair with any chars but } in between and capture that
val curlyBraceRegex = Regex("""\{([^}]*)\}""")
// Analogous as above: Expect a <> pair with any chars but > in between and capture that
val pointyBraceRegex = Regex("""\<([^>]*)\>""")
/**
* This function does the actual translation work,
* using an instance of [Translations] stored in UncivGame.Current
@ -211,6 +246,7 @@ val curlyBraceRegex = Regex("""\{([^}]*)\}""")
* placeholders - contains at least one '[' - see below
* sentences - contains at least one '{'
* - phrases between curly braces are translated individually
* Additionally, they may contain conditionals between '<' and '>'
* @return The translated string
* defaults to the input string if no translation is available,
* but with placeholder or sentence brackets removed.
@ -219,6 +255,61 @@ fun String.tr(): String {
val activeMods = with(UncivGame.Current) {
if (isGameInfoInitialized()) gameInfo.gameParameters.mods else translations.translationActiveMods
}
val language = UncivGame.Current.settings.language
if (contains('<')) { // Conditionals!
/**
* So conditionals can contain placeholders, such as <vs [unitFilter] units>, which themselves
* can contain multiple filters, such as <vs [{Military} {Water}] units>.
* Moreover, we can have any amount of conditionals in any order, and translations
* can reorder these conditionals in any way they like, even putting them in front
* of the rest of the translatable string.
* All of this nesting makes it quite difficult to translate, and is the reason we check
* for these first.
*
* The plan: First translate each of the conditionals on its own, and then combine them
* together into the final fully translated string.
*/
val translatedBaseText = this.removeConditionals().tr()
val conditionals = this.getConditionals().map { it.placeholderText }
val conditionsWithTranslation: HashMap<String, String> = hashMapOf()
for (conditional in this.getConditionals())
conditionsWithTranslation[conditional.placeholderText] = conditional.text.tr()
val translatedConditionals: MutableList<String> = mutableListOf()
// Somewhere, we asked the translators to reorder all possible conditionals in a way that
// makes sense in their language. We get this ordering, and than extract each of the
// translated conditionals, removing the <> surrounding them, and removing param values
// where it exists.
val conditionalOrdering = UncivGame.Current.translations.getConditionalOrder(language)
for (placedConditional in pointyBraceRegex.findAll(conditionalOrdering).map { it.value.substring(1, it.value.length-1).getPlaceholderText() }) {
if (placedConditional in conditionals) {
translatedConditionals.add(conditionsWithTranslation[placedConditional]!!)
conditionsWithTranslation.remove(placedConditional)
}
}
// If the translated string that should contain all conditionals doesn't contain
// a few conditionals used here, just add the translations of these to the end.
// We do test for this, but just in case.
translatedConditionals.addAll(conditionsWithTranslation.values)
// After that, add the translation of the base unique either before or after these conditionals
if (UncivGame.Current.translations.placeConditionalsAfterUnique(language)) {
translatedConditionals.add(0, translatedBaseText)
} else {
translatedConditionals.add(translatedBaseText)
}
var fullyTranslatedString = translatedConditionals.joinToString(UncivGame.Current.translations.getSpaceEquivalent(language))
if (UncivGame.Current.translations.shouldCapitalize(language))
fullyTranslatedString = fullyTranslatedString.replaceFirstChar { it.uppercase() }
return fullyTranslatedString
}
// There might still be optimization potential here!
if (contains('[')) { // Placeholders!
@ -237,7 +328,6 @@ fun String.tr(): String {
// Convert "work on [building] has completed in [city]" to "work on [] has completed in []"
val translationStringWithSquareBracketsOnly = this.getPlaceholderText()
val language = UncivGame.Current.settings.language
// That is now the key into the translation HashMap!
val translationEntry = UncivGame.Current.translations
.get(translationStringWithSquareBracketsOnly, language, activeMods)
@ -272,10 +362,12 @@ fun String.tr(): String {
if (Stats.isStats(this)) return Stats.parse(this).toString()
return UncivGame.Current.translations.getText(this, UncivGame.Current.settings.language, activeMods)
return UncivGame.Current.translations.getText(this, language, activeMods)
}
fun String.getPlaceholderText() = this.replace(squareBraceRegex, "[]")
fun String.getPlaceholderText() = this
.replace(squareBraceRegex, "[]")
.removeConditionals()
fun String.equalsPlaceholderText(str:String): Boolean {
if (first() != str.first()) return false // for quick negative return 95% of the time
@ -297,3 +389,17 @@ fun String.fillPlaceholders(vararg strings: String): String {
filledString = filledString.replaceFirst(keys[i], strings[i])
return filledString
}
fun String.getConditionals() = pointyBraceRegex.findAll(this).map { Unique(it.groups[1]!!.value) }.toList()
fun String.removeConditionals() = this
.replace(pointyBraceRegex, "")
// So, this is a quick hack, but it works as long as nobody uses word separators different from " " (space) and "" (none),
// And no translations start or end with a space.
// According to https://linguistics.stackexchange.com/questions/6131/is-there-a-long-list-of-languages-whose-writing-systems-dont-use-spaces
// This is a reasonable but not fully correct assumption to make.
// By doing it like this, we exclude languages such as Tibetan, Dzongkha (Bhutan), and Ethiopian.
// If we ever start getting translations for these, we'll work something out then.
.replace(" ", " ")
.trim()

View File

@ -7,10 +7,7 @@ import com.unciv.models.UnitActionType
import com.unciv.models.metadata.GameSettings
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.TranslationFileWriter
import com.unciv.models.translations.Translations
import com.unciv.models.translations.squareBraceRegex
import com.unciv.models.translations.tr
import com.unciv.models.translations.*
import org.junit.Assert
import org.junit.Before
import org.junit.Test
@ -27,7 +24,7 @@ class TranslationTests {
@Before
fun loadTranslations() {
// Since the ruleset and translation loader have their own output,
// We 'disable' the output stream for their outputs, and only enable it for the twst itself.
// We 'disable' the output stream for their outputs, and only enable it for the test itself.
val outputChannel = System.out
System.setOut(PrintStream(object : OutputStream() {
override fun write(b: Int) {}
@ -188,4 +185,48 @@ class TranslationTests {
allWordsTranslatedCorrectly
)
}
@Test
fun wordBoundaryTranslationIsFormattedCorrectly() {
val translationEntry = translations["\" \""]!!
var allTranslationsCheckedOut = true
for ((language, translation) in translationEntry) {
if (!translation.startsWith("\"")
|| !translation.endsWith("\"")
|| translation.count { it == '\"' } != 2
) {
allTranslationsCheckedOut = false
println("Translation of the word boundary in $language was incorrectly formatted")
}
}
Assert.assertTrue(
"This test will only pass when the word boundrary translation succeeds",
allTranslationsCheckedOut
)
}
@Test
fun allConditionalsAreContainedInConditionalOrderTranslation() {
val orderedConditionals = Translations.englishConditionalOrderingString
val orderedConditionalsSet = orderedConditionals.getConditionals().map { it.placeholderText }
val translationEntry = translations[orderedConditionals]!!
var allTranslationsCheckedOut = true
for ((language, translation) in translationEntry) {
val translationConditionals = translation.getConditionals().map { it.placeholderText }
if (translationConditionals.toHashSet() != orderedConditionalsSet.toHashSet()
|| translationConditionals.count() != translationConditionals.distinct().count()
) {
allTranslationsCheckedOut = false
println("Not all or double parameters found in the conditional ordering for $language")
}
}
Assert.assertTrue(
"This test will only pass when each of the conditionals exists exactly once in the translations for the conditional ordering",
allTranslationsCheckedOut
)
}
}