diff --git a/android/assets/jsons/Civ V - Vanilla/Beliefs.json b/android/assets/jsons/Civ V - Vanilla/Beliefs.json index d2a72955c9..b39107b720 100644 --- a/android/assets/jsons/Civ V - Vanilla/Beliefs.json +++ b/android/assets/jsons/Civ V - Vanilla/Beliefs.json @@ -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] "] }, ///////////////////////////////////////// Founder beliefs ////////////////////////////////////////// diff --git a/android/assets/jsons/Civ V - Vanilla/Policies.json b/android/assets/jsons/Civ V - Vanilla/Policies.json index 443ff96ac3..ca36b889b1 100644 --- a/android/assets/jsons/Civ V - Vanilla/Policies.json +++ b/android/assets/jsons/Civ V - Vanilla/Policies.json @@ -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"] } ] }, diff --git a/android/assets/jsons/translations/Dutch.properties b/android/assets/jsons/translations/Dutch.properties index fa70c99bb6..3fe0935776 100644 --- a/android/assets/jsons/translations/Dutch.properties +++ b/android/assets/jsons/translations/Dutch.properties @@ -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 + = \ No newline at end of file diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 4b7b2e6ed1..cfed61ad94 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -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 ". +# In this case "" 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 ", should the +# 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 +# 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. + + = + + + + + +# AUTOMATICALLY GENERATED TRANSLATABLE STRINGS + + diff --git a/core/src/com/unciv/logic/city/CityStats.kt b/core/src/com/unciv/logic/city/CityStats.kt index b393259494..7e61040b27 100644 --- a/core/src/com/unciv/logic/city/CityStats.kt +++ b/core/src/com/unciv/logic/city/CityStats.kt @@ -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 } diff --git a/core/src/com/unciv/models/ruleset/Unique.kt b/core/src/com/unciv/models/ruleset/Unique.kt index 4ce5546630..96fcaed21c 100644 --- a/core/src/com/unciv/models/ruleset/Unique.kt +++ b/core/src/com/unciv/models/ruleset/Unique.kt @@ -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 = 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>() { fun getUniques(uniqueType: UniqueType) = getUniques(uniqueType.placeholderText) fun getAllUniques() = this.asSequence().flatMap { it.value.asSequence() } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/models/translations/TranslationFileWriter.kt b/core/src/com/unciv/models/translations/TranslationFileWriter.kt index bac7574066..d711ae493c 100644 --- a/core/src/com/unciv/models/translations/TranslationFileWriter.kt +++ b/core/src/com/unciv/models/translations/TranslationFileWriter.kt @@ -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) diff --git a/core/src/com/unciv/models/translations/Translations.kt b/core/src/com/unciv/models/translations/Translations.kt index 229aca944a..d85040eca0 100644 --- a/core/src/com/unciv/models/translations/Translations.kt +++ b/core/src/com/unciv/models/translations/Translations.kt @@ -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(){ - + var percentCompleteOfLanguages = HashMap() .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(){ 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 = " " + 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 , which themselves + * can contain multiple filters, such as . + * 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 = hashMapOf() + + for (conditional in this.getConditionals()) + conditionsWithTranslation[conditional.placeholderText] = conditional.text.tr() + + val translatedConditionals: MutableList = 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() + diff --git a/tests/src/com/unciv/testing/TranslationTests.kt b/tests/src/com/unciv/testing/TranslationTests.kt index de70a06554..27c5aebb98 100644 --- a/tests/src/com/unciv/testing/TranslationTests.kt +++ b/tests/src/com/unciv/testing/TranslationTests.kt @@ -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 + ) + } }