From b25937f986126c3b126e28f845b2970dc4e33247 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 05:03:21 -0400 Subject: [PATCH] Download translations on demand instead of bundling them Locales are fetched from the Iris repository at runtime into languages/downloaded and cached against the revision the jar was built from; the artifact ships only a manifest of the available locales. IrisLanguage gains a player language service, per-audience snapshots and an in-game message editor that writes locale overrides. The Bukkit artifact verifier now fails when any locale file is embedded, and the modded shadow jars drop the Bukkit-only switcher and editor classes. --- adapters/fabric/build.gradle | 3 + adapters/forge/build.gradle | 3 + adapters/neoforge/build.gradle | 3 + build.gradle | 59 +--- .../src/main/java/BukkitArtifactVerifier.java | 7 +- .../test/java/BukkitArtifactVerifierTest.java | 27 +- core/build.gradle | 21 +- .../iris/core/localization/IrisLanguage.java | 334 +++++++++++++----- .../localization/IrisLanguageEditorTest.java | 110 ++++++ .../core/localization/IrisLanguageTest.java | 96 +++-- 10 files changed, 472 insertions(+), 191 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 467cea14c..470d91077 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -260,6 +260,9 @@ tasks.named('shadowJar', ShadowJar).configure { // archive as versioned and re-scan it per release directory for nothing. exclude('META-INF/versions/**') addMultiReleaseAttribute.set(false) + exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class') + exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class') + exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class') // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. exclude('org/apache/commons/lang/enum/**') // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 513e806a7..a8d6c4baa 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -252,6 +252,9 @@ tasks.named('shadowJar', ShadowJar).configure { // archive as versioned and re-scan it per release directory for nothing. exclude('META-INF/versions/**') addMultiReleaseAttribute.set(false) + exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class') + exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class') + exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class') // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. exclude('org/apache/commons/lang/enum/**') // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 841a29891..83bc6a8e7 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -228,6 +228,9 @@ tasks.named('shadowJar', ShadowJar).configure { // archive as versioned and re-scan it per release directory for nothing. exclude('META-INF/versions/**') addMultiReleaseAttribute.set(false) + exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class') + exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class') + exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class') // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. exclude('org/apache/commons/lang/enum/**') // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. diff --git a/build.gradle b/build.gradle index bc5a2d555..cd0e144fb 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,6 @@ import org.gradle.api.tasks.Copy import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.tasks.Jar import org.gradle.jvm.toolchain.JavaLanguageVersion -import groovy.json.JsonOutput -import groovy.json.JsonSlurper /* * Iris is a World Generator for Minecraft Bukkit Servers @@ -147,16 +145,6 @@ nmsBindings.each { key, value -> def included = configurations.create('included') def jarJar = configurations.create('jarJar') -def bukkitLanguagesDirectory = layout.buildDirectory.dir('generated/bukkit-languages') -Set sharedNonEnglishBukkitMessages = [ - 'iris.desktop.vision.fps', - 'iris.desktop.vision.tiles', - 'iris.bukkit.runtime.commandpack.message_2', - 'iris.bukkit.commanddatapack.message_2', - 'iris.runtime.chunk_job.bossbar.progress', - 'iris.runtime.studio.action.progress', - 'iris.runtime.chunk_job.action.progress' -] as Set dependencies { nmsBindings.keySet().each { key -> add('included', project(path: ":adapters:bukkit:nms:${key}", configuration: 'runtimeElements')) @@ -166,48 +154,6 @@ dependencies { add('jarJar', project(':core:agent')) } -def prepareBukkitLanguages = tasks.register('prepareBukkitLanguages') { - inputs.files(fileTree(project(':core').file('src/main/resources/languages')) { - include('*.json') - }) - outputs.dir(bukkitLanguagesDirectory) - doLast { - File outputDirectory = bukkitLanguagesDirectory.get().asFile - delete(outputDirectory) - outputDirectory.mkdirs() - List sources = fileTree(project(':core').file('src/main/resources/languages')) { - include('*.json') - }.files.sort { File left, File right -> left.name <=> right.name } - List> catalogs = sources.collect { File source -> - (Map) new JsonSlurper().parse(source) - } - Map referenceMessages = (Map) catalogs.first().get('messages') - List messageIds = referenceMessages.keySet() - .findAll { String key -> !key.startsWith('iris.modded.') } - .sort() - Set sharedFallbackIds = new LinkedHashSet<>(messageIds) - catalogs.each { Map catalog -> - Map messages = (Map) catalog.get('messages') - if (!messages.keySet().containsAll(messageIds)) { - throw new GradleException("Bundled locale ${catalog.get('locale')} is missing Bukkit messages") - } - sharedFallbackIds.removeIf { String key -> messages.get(key) != referenceMessages.get(key) } - } - sharedFallbackIds.removeAll(sharedNonEnglishBukkitMessages) - - sources.eachWithIndex { File source, int index -> - Map catalog = catalogs.get(index) - Map messages = (Map) catalog.get('messages') - List compactMessages = messageIds.collect { String key -> - sharedFallbackIds.contains(key) ? null : messages.get(key) - } - new File(outputDirectory, source.name).setText( - JsonOutput.toJson([catalog.get('locale'), compactMessages]), - 'UTF-8') - } - } -} - tasks.named('jar', Jar).configure { inputs.files(included) duplicatesStrategy = DuplicatesStrategy.EXCLUDE @@ -216,9 +162,6 @@ tasks.named('jar', Jar).configure { from(provider { included.resolve().collect { zipTree(it) } }) { exclude('languages/**') } - from(prepareBukkitLanguages) { - into('languages') - } doFirst { delete(layout.buildDirectory.file("libs/Iris-${project.version}.jar")) } @@ -591,6 +534,7 @@ List requiredBukkitArtifactEntries = [ 'paper-plugin.yml', 'slimjar.dat', 'slimjar-resolutions.dat', + 'iris-language-source.properties', 'agent.jar', 'art/arcane/iris/Iris.class', 'art/arcane/iris/IrisBootstrap.class', @@ -618,7 +562,6 @@ tasks.register('verifyBukkitArtifact') { BukkitArtifactVerifier.verify( artifact, requiredBukkitArtifactEntries, - 17, 16, maximumBukkitArtifactBytes) logger.lifecycle("Verified ${artifact.name} packaging and class reference graph") diff --git a/buildSrc/src/main/java/BukkitArtifactVerifier.java b/buildSrc/src/main/java/BukkitArtifactVerifier.java index 17bbcaef1..8d4503923 100644 --- a/buildSrc/src/main/java/BukkitArtifactVerifier.java +++ b/buildSrc/src/main/java/BukkitArtifactVerifier.java @@ -47,7 +47,7 @@ public final class BukkitArtifactVerifier { private BukkitArtifactVerifier() { } - public static void verify(File artifact, List requiredEntries, int minimumLocales, + public static void verify(File artifact, List requiredEntries, int minimumMatterSlices, long maximumArtifactBytes) { if (!artifact.isFile()) { throw new GradleException("Missing Bukkit Iris artifact: " + artifact.getAbsolutePath()); @@ -92,9 +92,8 @@ public final class BukkitArtifactVerifier { } } - if (locales < minimumLocales) { - throw new GradleException(artifact.getName() + " ships " + locales + " locale files; expected at least " - + minimumLocales); + if (locales != 0) { + throw new GradleException(artifact.getName() + " ships " + locales + " locale files; translations must download on demand"); } // Matter.read() resolves slice types from the canonical name stored in the payload. if (matterSlices < minimumMatterSlices) { diff --git a/buildSrc/src/test/java/BukkitArtifactVerifierTest.java b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java index ad0715f40..c92ebacae 100644 --- a/buildSrc/src/test/java/BukkitArtifactVerifierTest.java +++ b/buildSrc/src/test/java/BukkitArtifactVerifierTest.java @@ -29,7 +29,6 @@ public class BukkitArtifactVerifierTest { SLIMJAR_RESOLUTIONS, NMS_BINDING + ".class" ); - private static final int LOCALES = 2; private static final int MATTER_SLICES = 1; @Rule @@ -39,7 +38,7 @@ public class BukkitArtifactVerifierTest { public void acceptsCompleteArtifact() throws Exception { File artifact = createArtifact(validEntries()); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE); } @Test @@ -49,7 +48,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(PLUGIN_DESCRIPTOR)); } @@ -60,7 +59,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(SLIMJAR_DEPENDENCIES)); } @@ -71,7 +70,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains(SLIMJAR_RESOLUTIONS)); } @@ -82,7 +81,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("art/arcane/volmlib/util/noise/CNG")); } @@ -101,7 +100,7 @@ public class BukkitArtifactVerifierTest { classReferencing("art/arcane/iris/ParalithicConsumer", "art/arcane/iris/util/paralithic/functions/Function")); File artifact = createArtifact(entries); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE); } @Test @@ -112,20 +111,19 @@ public class BukkitArtifactVerifierTest { () -> BukkitArtifactVerifier.verify( artifact, REQUIRED_ENTRIES, - LOCALES, MATTER_SLICES, artifact.length() - 1L)); assertTrue(failure.getMessage().contains("must not exceed")); } @Test - public void rejectsDroppedLocale() throws Exception { + public void rejectsEmbeddedLocale() throws Exception { Map entries = validEntries(); - entries.remove("languages/de_DE.json"); + entries.put("languages/de_DE.json", "{}".getBytes(StandardCharsets.UTF_8)); File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("locale files")); } @@ -136,7 +134,7 @@ public class BukkitArtifactVerifierTest { File artifact = createArtifact(entries); GradleException failure = assertThrows(GradleException.class, - () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE)); + () -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE)); assertTrue(failure.getMessage().contains("Matter slice types")); } @@ -147,7 +145,7 @@ public class BukkitArtifactVerifierTest { classAnnotatedWith("art/arcane/iris/Annotated", "com/google/errorprone/annotations/CanIgnoreReturnValue")); File artifact = createArtifact(entries); - BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE); + BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE); } private Map validEntries() { @@ -155,8 +153,7 @@ public class BukkitArtifactVerifierTest { entries.put(PLUGIN_DESCRIPTOR, "name: Iris\n".getBytes(StandardCharsets.UTF_8)); entries.put(SLIMJAR_DEPENDENCIES, new byte[]{1}); entries.put(SLIMJAR_RESOLUTIONS, new byte[]{1}); - entries.put("languages/de_DE.json", "{}".getBytes(StandardCharsets.UTF_8)); - entries.put("languages/fr_FR.json", "{}".getBytes(StandardCharsets.UTF_8)); + entries.put(NMS_BINDING + ".class", classReferencing(NMS_BINDING, "art/arcane/volmlib/util/noise/CNG")); entries.put("art/arcane/volmlib/util/noise/CNG.class", emptyClass("art/arcane/volmlib/util/noise/CNG")); diff --git a/core/build.gradle b/core/build.gradle index 18a483d58..d63e189b0 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -38,6 +38,24 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') .orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186') .get() +String languageSourceReference = providers.gradleProperty('languageRevision').orElse('master').get() +List languageSourceFiles = fileTree('src/main/resources/languages') { + include('*.json') +}.files.toList().sort { File left, File right -> left.name <=> right.name } +List languageSourceLocales = languageSourceFiles.collect { File source -> + source.name.substring(0, source.name.length() - '.json'.length()) +} +def languageSourceManifestTask = tasks.register('generateLanguageSourceManifest', WriteProperties) { + destinationFile.set(layout.buildDirectory.file('generated/language-source/iris-language-source.properties')) + property('revision', languageSourceReference) + property('locales', languageSourceLocales.join(',')) +} +tasks.named('processResources').configure { + dependsOn(languageSourceManifestTask) + exclude('languages/**') + from(languageSourceManifestTask) +} + /** * Dependencies. * @@ -218,7 +236,8 @@ tasks.named('compileJava', JavaCompile).configure { tasks.named('test', Test).configure { jvmArgs('--add-modules', 'jdk.incubator.vector') - classpath = files('src/main/resources') + classpath + dependsOn(languageSourceManifestTask) + classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + classpath } configurations.matching { it.name.startsWith('slim') }.all { } diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index b0446c0b1..d2cbe6628 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -5,6 +5,12 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.director.DirectorTextResolver; import art.arcane.volmlib.util.localization.LinesKey; +import art.arcane.volmlib.util.localization.LanguageAudience; +import art.arcane.volmlib.util.localization.LanguageFileEditor; +import art.arcane.volmlib.util.localization.PluginLanguageEditor; +import art.arcane.volmlib.util.localization.PluginLanguageService; +import art.arcane.volmlib.util.localization.RemoteLanguageCatalog; +import art.arcane.volmlib.util.localization.LocalizationValidator; import art.arcane.volmlib.util.localization.LocaleOverlay; import art.arcane.volmlib.util.localization.LocalizationCandidate; import art.arcane.volmlib.util.localization.LocalizationIssue; @@ -16,20 +22,32 @@ import art.arcane.volmlib.util.localization.MessageArgumentKind; import art.arcane.volmlib.util.localization.MessageArgs; import art.arcane.volmlib.util.localization.MessageCatalog; import art.arcane.volmlib.util.localization.MessageKey; +import art.arcane.volmlib.util.localization.MessageValue; +import art.arcane.volmlib.util.localization.LinesValue; +import art.arcane.volmlib.util.localization.PluralValue; +import art.arcane.volmlib.util.localization.TextValue; import art.arcane.volmlib.util.localization.PluralKey; import art.arcane.volmlib.util.localization.PluralSelector; import art.arcane.volmlib.util.localization.ResolvedLines; import art.arcane.volmlib.util.localization.ResolvedText; import art.arcane.volmlib.util.localization.TextKey; -import art.arcane.volmlib.util.localization.VolmitLocales; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.google.gson.GsonBuilder; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import com.google.gson.Gson; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; @@ -51,15 +69,12 @@ import java.util.function.BiConsumer; import java.util.regex.Pattern; public final class IrisLanguage { + private static final Object SNAPSHOT_LOCK = new Object(); private static final long MAX_LOCALE_BYTES = 2L * 1024L * 1024L; private static final int MAX_REPORTED_ISSUES = 12; private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+"); private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); private static final MessageCatalog CATALOG = IrisMessages.catalog(); - private static final List BUKKIT_MESSAGE_IDS = CATALOG.ids().stream() - .filter(id -> !id.startsWith("iris.modded.")) - .sorted() - .toList(); private static final LocalizationManager MANAGER = new LocalizationManager( LocalizationCandidate.english(CATALOG, PluralSelector.oneOther()) ); @@ -74,6 +89,9 @@ public final class IrisLanguage { new CopyOnWriteArrayList<>(); private static volatile File dataFolder; private static volatile String activeLocale = CATALOG.englishLocale(); + private static volatile RemoteLanguageCatalog remote; + private static volatile Path remoteRoot; + private static volatile PluginLanguageService selections; private IrisLanguage() { } @@ -82,7 +100,9 @@ public final class IrisLanguage { if (!IrisPlatforms.isBound()) { return false; } - return reload(IrisPlatforms.get().dataFolder(), configuredLocale()); + boolean loaded = reload(IrisPlatforms.get().dataFolder(), configuredLocale()); + start(); + return loaded; } public static synchronized boolean reload() { @@ -201,20 +221,30 @@ public final class IrisLanguage { private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) { LocalizationReloadResult result; - if (capture.failure() == null) { - result = MANAGER.reload(() -> loadCandidate(root, requestedLocale, capture.snapshot())); - } else { - result = MANAGER.reload(() -> { - throw capture.failure(); - }); + synchronized (SNAPSHOT_LOCK) { + if (capture.failure() == null) { + result = MANAGER.reload(() -> loadCandidate(root, requestedLocale, capture.snapshot())); + } else { + result = MANAGER.reload(() -> { + throw capture.failure(); + }); + } + dataFolder = root; + if (result.applied()) { + activeLocale = requestedLocale; + } } - dataFolder = root; if (!result.applied()) { reportRejectedReload(requestedLocale, result); return false; } - activeLocale = requestedLocale; + PluginLanguageService current = selections; + if (current != null) { + current.invalidate(); + current.cache(activeLocale, MANAGER.snapshot()); + requestConfiguredLocale(); + } int warnings = result.validation().warnings().size(); IrisLogging.debug("Loaded locale " + requestedLocale + " with " + warnings + " fallback " + (warnings == 1 ? "entry" : "entries") + "."); @@ -274,7 +304,7 @@ public final class IrisLanguage { } public static String plain(MessageKey key) { - LocalizationSnapshot snapshot = MANAGER.snapshot(); + LocalizationSnapshot snapshot = snapshot(); PlainMemo memo = PLAIN_MEMO.get(); if (memo == null || memo.snapshot() != snapshot) { memo = new PlainMemo(snapshot, new ConcurrentHashMap<>()); @@ -284,7 +314,7 @@ public final class IrisLanguage { if (cached != null) { return cached; } - String resolved = plain(key, MessageArgs.empty()); + String resolved = IrisLogging.clean(render(resolve(snapshot, key, MessageArgs.empty()))); memo.values().put(key.id(), resolved); return resolved; } @@ -317,14 +347,18 @@ public final class IrisLanguage { } private static ResolvedText resolve(MessageKey key, MessageArgs arguments) { + return resolve(snapshot(), key, arguments); + } + + private static ResolvedText resolve(LocalizationSnapshot snapshot, MessageKey key, MessageArgs arguments) { if (key instanceof TextKey textKey) { - return MANAGER.snapshot().resolve(textKey, arguments); + return snapshot.resolve(textKey, arguments); } if (key instanceof PluralKey pluralKey) { - return MANAGER.snapshot().resolve(pluralKey, arguments); + return snapshot.resolve(pluralKey, arguments); } if (key instanceof LinesKey linesKey) { - ResolvedLines lines = MANAGER.snapshot().resolve(linesKey, arguments); + ResolvedLines lines = snapshot.resolve(linesKey, arguments); return new ResolvedText(lines.key(), lines.locale(), String.join("\n", lines.lines()), lines.arguments()); } throw new IllegalArgumentException("Unsupported Iris message key: " + key.id()); @@ -343,41 +377,216 @@ public final class IrisLanguage { } if (!CATALOG.englishLocale().equals(locale)) { - LocaleOverlay bundled = loadBundledOverlay(locale); - if (bundled != null) { - overlays.add(bundled); + LocaleOverlay downloaded = loadDownloadedOverlay(root, locale); + if (downloaded != null) { + overlays.add(downloaded); } } return new LocalizationCandidate(CATALOG, overlays, PluralSelector.oneOther()); } - static LocaleOverlay loadBundledOverlay(String locale) throws Exception { - String normalizedLocale = normalizeLocale(locale); - if (CATALOG.englishLocale().equals(normalizedLocale)) { - return null; + static RemoteLanguageCatalog remote(File root) { + Path path = root.toPath().toAbsolutePath().normalize(); + RemoteLanguageCatalog current = remote; + if (current != null && path.equals(remoteRoot)) { + return current; } - String resourceName = "/languages/" + normalizedLocale + ".json"; - InputStream input = IrisLanguage.class.getResourceAsStream(resourceName); - if (input == null) { - if (VolmitLocales.isBundled(normalizedLocale)) { - throw new IllegalStateException("Missing bundled Iris locale resource: " + resourceName); + synchronized (IrisLanguage.class) { + if (remote != null && path.equals(remoteRoot)) { + return remote; } - return null; - } - try (InputStream stream = input) { - byte[] bytes = stream.readNBytes((int) MAX_LOCALE_BYTES + 1); - if (bytes.length > MAX_LOCALE_BYTES) { - throw new IllegalArgumentException("Bundled locale is too large: " + resourceName); + if (remote != null) { + remote.close(); } - return parseOverlay("bundled:" + resourceName, normalizedLocale, new String(bytes, StandardCharsets.UTF_8)); + remote = RemoteLanguageCatalog.load(new RemoteLanguageCatalog.Options( + "Iris", + URI.create("https://raw.githubusercontent.com/VolmitSoftware/Iris/"), + "core/src/main/resources/languages", + ".json", + "iris-language-source.properties", + path.resolve("languages/downloaded"), + IrisLanguage.class.getClassLoader() + )); + remoteRoot = path; + return remote; } } + public static synchronized void start() { + if (selections != null || dataFolder == null) { + return; + } + selections = new PluginLanguageService(new PluginLanguageService.Options( + dataFolder.toPath().resolve("languages/players.properties"), + IrisLanguage::availableLocales, + IrisLanguage::activeLocale, + MANAGER::snapshot, + IrisLanguage::prepareLocale, + IrisLanguage::selectDefault, + Logger.getLogger("Iris") + )); + requestConfiguredLocale(); + } + + public static synchronized void shutdown() { + PluginLanguageService current = selections; + selections = null; + if (current != null) { + current.close(); + } + RemoteLanguageCatalog currentRemote = remote; + remote = null; + remoteRoot = null; + if (currentRemote != null) { + currentRemote.close(); + } + } + + public static PluginLanguageService selections() { + return selections; + } + + public static Set availableLocales() { + Set locales = new LinkedHashSet<>(); + locales.add(CATALOG.englishLocale()); + locales.addAll(remote(dataFolder).availableLocales()); + return Set.copyOf(locales); + } + + public static String text(UUID player, MessageKey key, MessageArgs arguments) { + return LanguageAudience.call(player, () -> text(key, arguments)); + } + + public static String plain(UUID player, MessageKey key, MessageArgs arguments) { + return LanguageAudience.call(player, () -> plain(key, arguments)); + } + + private static LocalizationSnapshot snapshot() { + PluginLanguageService current = selections; + return current == null ? MANAGER.snapshot() : current.snapshot(); + } + + private static LocalizationSnapshot prepareLocale(String locale) throws Exception { + File root = dataFolder; + if (!CATALOG.englishLocale().equals(locale) && remote(root).availableLocales().contains(locale)) { + remote(root).readOrDownload(locale, IrisLanguage::validateDownload); + } + SnapshotCapture capture = captureForReload(overrideFile(root, locale), locale); + if (capture.failure() != null) { + throw capture.failure(); + } + return LocalizationSnapshot.create(loadCandidate(root, locale, capture.snapshot())); + } + + public static PluginLanguageEditor.Options editorOptions() { + return new PluginLanguageEditor.Options(IrisLanguage::prepareLocale, IrisLanguage::writeMessage); + } + + private static LocalizationSnapshot writeMessage(PluginLanguageEditor.Edit edit) throws IOException { + File root = dataFolder; + File file = overrideFile(root, edit.locale()); + LocalizationSnapshot prepared = LanguageFileEditor.update(file.toPath(), raw -> { + LocalizationSnapshot current = editorSnapshot(root, file, edit.locale(), raw); + if (!current.value(CATALOG.require(edit.key())).equals(edit.expected())) { + throw new IOException("Language message changed; reopen it before saving"); + } + String updated = writeOverride(raw, edit); + return new LanguageFileEditor.Prepared<>(updated, editorSnapshot(root, file, edit.locale(), updated)); + }); + synchronized (SNAPSHOT_LOCK) { + if (root.equals(dataFolder) && edit.locale().equals(activeLocale)) { + MANAGER.install(prepared); + } + } + return prepared; + } + + private static LocalizationSnapshot editorSnapshot(File root, File file, String locale, String raw) throws IOException { + try { + LocaleHotloadSnapshot source = LocaleHotloadSnapshot.present(file, locale, raw, sha256(raw.getBytes(StandardCharsets.UTF_8))); + return LocalizationSnapshot.create(loadCandidate(root, locale, source)); + } catch (Exception failure) { + throw new IOException("Could not validate Iris language " + locale, failure); + } + } + + private static String writeOverride(String raw, PluginLanguageEditor.Edit edit) { + Map values = new LinkedHashMap<>(parseOverlay("editor", edit.locale(), raw).values()); + values.put(edit.key(), edit.value()); + JsonObject messages = new JsonObject(); + Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + for (Map.Entry entry : values.entrySet()) { + JsonElement value = switch (entry.getValue()) { + case TextValue text -> gson.toJsonTree(text.template()); + case LinesValue lines -> gson.toJsonTree(lines.lines()); + case PluralValue plural -> gson.toJsonTree(plural.forms()); + }; + messages.add(entry.getKey(), value); + } + JsonObject document = new JsonObject(); + document.addProperty("locale", edit.locale()); + document.add("messages", messages); + return gson.toJson(document) + "\n"; + } + + private static void selectDefault(String locale, LocalizationSnapshot prepared) throws Exception { + IrisSettings current = IrisSettings.get(); + JsonObject serialized = new Gson().toJsonTree(current).getAsJsonObject(); + serialized.getAsJsonObject("general").addProperty("language", locale); + Path target = dataFolder.toPath().resolve("iris.json"); + Path temporary = Files.createTempFile(target.getParent(), "iris-", ".json.tmp"); + try { + Files.writeString(temporary, serialized.toString()); + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + current.getGeneral().setLanguage(locale); + synchronized (SNAPSHOT_LOCK) { + MANAGER.install(prepared); + activeLocale = locale; + } + } + + private static void requestConfiguredLocale() { + String locale = activeLocale; + if (CATALOG.englishLocale().equals(locale)) { + return; + } + File root = dataFolder; + remote(root).request(locale, IrisLanguage::validateDownload, result -> { + if (!result.successful()) { + IrisLogging.error("Failed to download Iris locale " + locale + "."); + IrisLogging.reportError(result.failure()); + return; + } + synchronized (IrisLanguage.class) { + if (root.equals(dataFolder) && locale.equals(activeLocale)) { + reloadResolved(root, locale, false); + } + } + }); + } + + private static void validateDownload(String locale, String raw) { + LocalizationValidator.validate(CATALOG, List.of(parseOverlay("download:" + locale, locale, raw))) + .throwIfInvalid(); + } + + private static LocaleOverlay loadDownloadedOverlay(File root, String locale) { + RemoteLanguageCatalog.CacheResult cached = remote(root).read(locale, IrisLanguage::validateDownload); + if (cached.state() == RemoteLanguageCatalog.CacheState.VALID) { + return parseOverlay(cached.file().toString(), locale, cached.content()); + } + if (cached.failure() != null) { + IrisLogging.error("Ignoring invalid downloaded Iris locale " + locale + "."); + IrisLogging.reportError(cached.failure()); + } + return null; + } + static LocaleOverlay parseOverlay(String source, String locale, String raw) { JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw); - if (parsed.isJsonArray()) { - return parseCompactBukkitOverlay(source, locale, parsed.getAsJsonArray()); - } if (!parsed.isJsonObject()) { throw new IllegalArgumentException("Locale source is not a JSON object: " + source); } @@ -405,49 +614,6 @@ public final class IrisLanguage { return builder.build(); } - private static LocaleOverlay parseCompactBukkitOverlay( - String source, - String locale, - JsonArray root - ) { - if (root.size() != 2 || !root.get(0).isJsonPrimitive() || !root.get(1).isJsonArray()) { - throw new IllegalArgumentException("Compact locale source is invalid: " + source); - } - if (!locale.equals(normalizeLocale(root.get(0).getAsString()))) { - throw new IllegalArgumentException("Locale source declares a different locale than its file: " + source); - } - JsonArray messages = root.get(1).getAsJsonArray(); - if (messages.size() != BUKKIT_MESSAGE_IDS.size()) { - throw new IllegalArgumentException("Compact locale message count is invalid: " + source); - } - LocaleOverlay.Builder builder = LocaleOverlay.builder(source, locale); - for (int i = 0; i < messages.size(); i++) { - JsonElement value = messages.get(i); - if (value == null || value.isJsonNull()) { - continue; - } - appendCompactMessage(builder, BUKKIT_MESSAGE_IDS.get(i), value); - } - return builder.build(); - } - - private static void appendCompactMessage( - LocaleOverlay.Builder builder, - String key, - JsonElement value - ) { - MessageKey definition = CATALOG.key(key); - if (value.isJsonObject() && definition instanceof PluralKey) { - builder.plural(key, readPlural(key, value.getAsJsonObject())); - } else if (value.isJsonArray()) { - builder.lines(key, readLines(key, value.getAsJsonArray())); - } else if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { - builder.text(key, value.getAsString()); - } else { - throw new IllegalArgumentException("Compact locale value has an invalid shape: " + key); - } - } - private static void appendMessages(LocaleOverlay.Builder builder, JsonObject object, String prefix) { for (Map.Entry entry : object.entrySet()) { String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java new file mode 100644 index 000000000..72d65438d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java @@ -0,0 +1,110 @@ +package art.arcane.iris.core.localization; + +import art.arcane.volmlib.util.localization.LocalizationCandidate; +import art.arcane.volmlib.util.localization.LocalizationSnapshot; +import art.arcane.volmlib.util.localization.PluginLanguageEditor; +import art.arcane.volmlib.util.localization.PluginLanguageService; +import art.arcane.volmlib.util.localization.PluralSelector; +import art.arcane.volmlib.util.localization.TextValue; +import art.arcane.volmlib.util.localization.VolmitLocales; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisLanguageEditorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private File directory; + private PluginLanguageService languages; + private PluginLanguageEditor editor; + + @Before + public void prepareEditor() throws Exception { + directory = temporaryFolder.newFolder(); + assertTrue(IrisLanguage.reload(directory, "en_US")); + Path cache = IrisLanguage.remote(directory).cacheFile("fr_FR"); + Files.createDirectories(cache.getParent()); + Files.copy(Path.of("src/main/resources/languages/fr_FR.json"), cache); + PluginLanguageEditor.Options options = IrisLanguage.editorOptions(); + LocalizationSnapshot english = LocalizationSnapshot.create( + LocalizationCandidate.english(IrisLanguage.catalog(), PluralSelector.oneOther())); + languages = new PluginLanguageService(new PluginLanguageService.Options( + directory.toPath().resolve("players.properties"), VolmitLocales::all, () -> "en_US", () -> english, + options.loader()::load, (locale, snapshot) -> { + throw new AssertionError("Editing must not select a server language"); + }, Logger.getLogger("IrisLanguageEditorTest"))); + editor = new PluginLanguageEditor(languages, options); + } + + @After + public void closeEditor() { + editor.close(); + languages.close(); + assertTrue(IrisLanguage.reload(directory, "en_US")); + IrisLanguage.shutdown(); + } + + @Test + public void savesOneLocaleAndRefreshesItsPersonalSnapshotWithoutSelectingIt() throws Exception { + UUID player = UUID.randomUUID(); + languages.selectPlayer(player, "fr_FR").get(5, TimeUnit.SECONDS); + PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + TextValue value = new TextValue("Langue \"{locale}\" rechargee.\nSuite."); + editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), + original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); + + Path file = directory.toPath().resolve("languages/overrides/fr_FR.json"); + assertTrue(Files.isRegularFile(file)); + assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS)); + assertEquals(value, languages.snapshot(player).value(IrisMessages.COMMAND_RELOAD_SUCCESS)); + assertEquals("fr_FR", languages.playerLocale(player).orElseThrow()); + assertEquals("en_US", languages.defaultLocale()); + assertEquals("en_US", IrisLanguage.activeLocale()); + assertFalse(Files.exists(directory.toPath().resolve("languages/overrides/en_US.json"))); + } + + @Test + public void invalidMessageLeavesTheLocaleFileIntact() throws Exception { + PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), + original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), new TextValue("Langue {locale}"))) + .get(5, TimeUnit.SECONDS); + Path file = directory.toPath().resolve("languages/overrides/fr_FR.json"); + byte[] before = Files.readAllBytes(file); + PluginLanguageEditor.Document saved = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + + assertThrows(ExecutionException.class, () -> editor.save(new PluginLanguageEditor.Edit("fr_FR", + IrisMessages.COMMAND_RELOAD_SUCCESS.id(), saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), + new TextValue("Missing placeholder"))).get(5, TimeUnit.SECONDS)); + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test + public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception { + Files.writeString(IrisLanguage.remote(directory).cacheFile("fr_FR"), "{\"locale\":\"fr_FR\",\"messages\":{}}"); + PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + TextValue value = new TextValue("Langue {locale}"); + PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), + original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); + assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS)); + assertEquals("en_US", languages.defaultLocale()); + assertEquals("en_US", IrisLanguage.activeLocale()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java index a7d10fa3d..c431e5b8c 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageTest.java @@ -23,12 +23,17 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ExecutionException; +import art.arcane.volmlib.util.localization.LanguageAudience; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -39,6 +44,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; public class IrisLanguageTest { private static final Pattern PLACEHOLDER = Pattern.compile("\\{[A-Za-z][A-Za-z0-9_]*}"); @@ -108,6 +114,11 @@ public class IrisLanguageTest { public void setUp() throws Exception { dataFolder = temporaryFolder.newFolder(); assertTrue(IrisLanguage.reload(dataFolder, "en_US")); + for (String locale : VolmitLocales.nonEnglish()) { + Path target = IrisLanguage.remote(dataFolder).cacheFile(locale); + Files.createDirectories(target.getParent()); + Files.copy(Path.of("src/main/resources/languages", locale + ".json"), target, StandardCopyOption.REPLACE_EXISTING); + } } @After @@ -115,6 +126,56 @@ public class IrisLanguageTest { assertTrue(IrisLanguage.reload(dataFolder, "en_US")); } + private LocaleOverlay loadSourceOverlay(String locale) throws Exception { + Path source = Path.of("src/main/resources/languages", locale + ".json"); + return IrisLanguage.parseOverlay(source.toString(), locale, Files.readString(source)); + } + + @Test + public void missingDownloadUsesCodeOwnedEnglish() throws Exception { + Files.delete(IrisLanguage.remote(dataFolder).cacheFile("de_DE")); + assertTrue(IrisLanguage.reload(dataFolder, "de_DE")); + assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)); + } + + @Test + public void failedServerSelectionRetainsSettingsAndRenderedLanguage() throws Exception { + IrisSettings previous = IrisSettings.settings; + IrisSettings current = new IrisSettings(); + IrisSettings.settings = current; + Files.createDirectory(dataFolder.toPath().resolve("iris.json")); + IrisLanguage.start(); + try { + assertThrows(ExecutionException.class, + () -> IrisLanguage.selections().selectDefault("de_DE").get(5, TimeUnit.SECONDS)); + assertEquals("en_US", current.getGeneral().getLanguage()); + assertEquals("en_US", IrisLanguage.activeLocale()); + assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)); + } finally { + IrisLanguage.shutdown(); + IrisSettings.settings = previous; + } + } + + @Test + public void personalSelectionDoesNotChangeAnotherPlayerOrTheServerDefault() throws Exception { + UUID germanPlayer = UUID.randomUUID(); + UUID otherPlayer = UUID.randomUUID(); + IrisLanguage.start(); + try { + IrisLanguage.selections().selectPlayer(germanPlayer, "de_DE").get(5, TimeUnit.SECONDS); + String german = LanguageAudience.call(germanPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)); + String other = LanguageAudience.call(otherPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)); + assertFalse("Unknown Iris command".equals(german)); + assertEquals("Unknown Iris command", other); + assertEquals("en_US", IrisLanguage.activeLocale()); + IrisLanguage.selections().clearPlayer(germanPlayer).get(5, TimeUnit.SECONDS); + assertEquals("Unknown Iris command", LanguageAudience.call(germanPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN))); + } finally { + IrisLanguage.shutdown(); + } + } + @Test public void usesCodeOwnedEnglishWithoutLocaleFile() { String rendered = IrisLanguage.plain( @@ -149,10 +210,10 @@ public class IrisLanguageTest { } @Test - public void bundledLocalesMatchSharedManifestAndCoverEntireCatalog() throws Exception { + public void downloadableLocalesMatchSharedManifestAndCoverEntireCatalog() throws Exception { assertEquals(17, VolmitLocales.nonEnglish().size()); for (String locale : VolmitLocales.nonEnglish()) { - LocaleOverlay overlay = IrisLanguage.loadBundledOverlay(locale); + LocaleOverlay overlay = loadSourceOverlay(locale); assertEquals(locale, overlay.locale()); assertEquals(IrisLanguage.catalog().ids(), overlay.values().keySet()); @@ -175,30 +236,7 @@ public class IrisLanguageTest { } @Test - public void compactBukkitLocaleUsesSortedCatalogPositionsAndEnglishFallbacks() { - List messageIds = IrisLanguage.catalog().ids().stream() - .filter(id -> !id.startsWith("iris.modded.")) - .sorted() - .toList(); - String translatedId = IrisMessages.COMMAND_UNKNOWN.id(); - JsonArray values = new JsonArray(messageIds.size()); - for (int i = 0; i < messageIds.size(); i++) { - values.add(messageIds.get(i).equals(translatedId) ? "Unbekannter Iris-Befehl" : null); - } - JsonArray compact = new JsonArray(2); - compact.add("de_DE"); - compact.add(values); - - LocaleOverlay overlay = IrisLanguage.parseOverlay("test", "de_DE", compact.toString()); - - assertEquals(Set.of(translatedId), overlay.values().keySet()); - assertEquals( - "Unbekannter Iris-Befehl", - ((TextValue) overlay.value(translatedId)).template()); - } - - @Test - public void bundledServerResourcesExactlyMatchNonEnglishManifest() throws Exception { + public void sourceLocalesExactlyMatchNonEnglishManifest() throws Exception { Set expected = VolmitLocales.nonEnglish().stream() .map(locale -> locale + ".json") .collect(Collectors.toUnmodifiableSet()); @@ -208,9 +246,9 @@ public class IrisLanguageTest { } @Test - public void bundledLocalesPreserveReservedWorldNames() throws Exception { + public void downloadableLocalesPreserveReservedWorldNames() throws Exception { for (String locale : VolmitLocales.nonEnglish()) { - LocaleOverlay overlay = IrisLanguage.loadBundledOverlay(locale); + LocaleOverlay overlay = loadSourceOverlay(locale); TextValue irisName = (TextValue) overlay.value( BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS.id() ); @@ -224,7 +262,7 @@ public class IrisLanguageTest { } @Test - public void bundledLocaleLoadsWithoutUserOverride() { + public void downloadedLocaleLoadsWithoutUserOverride() { assertTrue(IrisLanguage.reload(dataFolder, "de_DE")); assertEquals("de_DE", IrisLanguage.activeLocale()); assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));