From 1cd2f15e161316b3fb1b45672bab4ac98e79e69e Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 04:53:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(lang):=20=E4=BF=AE=E5=A4=8D=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E9=87=8D=E8=BD=BD=E4=B8=8E=E9=A2=9C=E8=89=B2=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minecraft/minecraft-chat/build.gradle.kts | 1 + .../java/taboolib/module/chat/HexColor.java | 94 ++--- .../main/kotlin/taboolib/module/chat/Util.kt | 19 +- .../taboolib/module/chat/ColorParsingTest.kt | 46 +++ .../minecraft/minecraft-i18n/build.gradle.kts | 4 + .../taboolib/module/lang/SnapshotHashMap.java | 365 ++++++++++++++++++ .../kotlin/taboolib/module/lang/Language.kt | 22 +- .../taboolib/module/lang/LanguageFile.kt | 10 +- .../taboolib/module/lang/ResourceReader.kt | 42 +- .../kotlin/taboolib/module/lang/TypeJson.kt | 41 +- .../module/lang/LanguageBoundaryTest.kt | 94 +++++ 11 files changed, 655 insertions(+), 83 deletions(-) create mode 100644 module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt create mode 100644 module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java create mode 100644 module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt diff --git a/module/minecraft/minecraft-chat/build.gradle.kts b/module/minecraft/minecraft-chat/build.gradle.kts index b090f0409..f96a00579 100644 --- a/module/minecraft/minecraft-chat/build.gradle.kts +++ b/module/minecraft/minecraft-chat/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { compileOnly(project(":common-env")) compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) + testImplementation("net.md-5:bungeecord-chat:1.21-R0.4") } \ No newline at end of file diff --git a/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java b/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java index 2d486b47e..2ca6a7b64 100644 --- a/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java +++ b/module/minecraft/minecraft-chat/src/main/java/taboolib/module/chat/HexColor.java @@ -2,6 +2,7 @@ import net.md_5.bungee.api.ChatColor; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.awt.*; import java.util.Optional; @@ -46,34 +47,25 @@ public static String translate(String in) { return ChatColor.translateAlternateColorCodes('&', in); } StringBuilder builder = new StringBuilder(); - char[] chars = in.toCharArray(); - for (int i = 0; i < chars.length; i++) { - if (i + 1 < chars.length && chars[i] == '&' && chars[i + 1] == '{') { - ChatColor chatColor = null; - char[] match = new char[0]; - for (int j = i + 2; j < chars.length && chars[j] != '}'; j++) { - match = arrayAppend(match, chars[j]); - } - if (match.length == 11 && (match[3] == ',' || match[3] == '-') && (match[7] == ',' || match[7] == '-')) { - chatColor = ChatColor.of(new Color(toInt(match, 0, 3), toInt(match, 4, 7), toInt(match, 8, 11))); - } else if (match.length == 7 && match[0] == '#') { - try { - chatColor = ChatColor.of(toString(match)); - } catch (IllegalArgumentException ignored) { - } - } else { - Optional knownColor = StandardColors.match(toString(match)); + for (int i = 0; i < in.length(); i++) { + if (i + 1 < in.length() && in.charAt(i) == '&' && in.charAt(i + 1) == '{') { + int end = in.indexOf('}', i + 2); + if (end >= 0) { + String expression = in.substring(i + 2, end).trim(); + Optional knownColor = StandardColors.match(expression); + Integer color = parseColor(expression); if (knownColor.isPresent()) { - chatColor = knownColor.get().toChatColor(); + builder.append(knownColor.get().toChatColor()); + i = end; + continue; + } else if (color != null) { + builder.append(ChatColor.of(new Color(color))); + i = end; + continue; } } - if (chatColor != null) { - builder.append(chatColor); - i += match.length + 2; - } - } else { - builder.append(chars[i]); } + builder.append(in.charAt(i)); } String colorString = builder.toString(); // 1.20.4 不再支持该写法,该模块无法判断版本,因此全部替换为白色 @@ -86,26 +78,42 @@ public static String getColorCode(int color) { return ChatColor.of(new Color(color)).toString(); } - private static char[] arrayAppend(char[] chars, char in) { - char[] newChars = new char[chars.length + 1]; - System.arraycopy(chars, 0, newChars, 0, chars.length); - newChars[chars.length] = in; - return newChars; - } - - private static String toString(char[] chars) { - StringBuilder builder = new StringBuilder(); - for (char c : chars) { - builder.append(c); + @Nullable + static Integer parseColor(String source) { + String value = source.trim(); + if (value.matches("#[0-9a-fA-F]{6}")) { + return Integer.parseInt(value.substring(1), 16); } - return builder.toString(); - } - - private static int toInt(char[] chars, int start, int end) { - StringBuilder builder = new StringBuilder(); - for (int i = start; i < end; i++) { - builder.append(chars[i]); + Character separator = null; + if (value.indexOf(',') >= 0) { + separator = ','; + } else if (value.indexOf('-') >= 0) { + separator = '-'; + } + if (separator != null) { + String[] parts = value.split("\\" + separator, -1); + if (parts.length != 3) { + return null; + } + int color = 0; + for (String part : parts) { + int component; + try { + component = Integer.parseInt(part.trim()); + } catch (NumberFormatException ignored) { + return null; + } + if (component < 0 || component > 255) { + return null; + } + color = color << 8 | component; + } + return color; + } + Optional knownColor = StandardColors.match(value); + if (knownColor.isPresent() && knownColor.get().toChatColor().getColor() != null) { + return knownColor.get().toChatColor().getColor().getRGB() & 0xFFFFFF; } - return Integer.parseInt(builder.toString()); + return null; } } diff --git a/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt b/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt index 763b74c39..0569404ce 100644 --- a/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt +++ b/module/minecraft/minecraft-chat/src/main/kotlin/taboolib/module/chat/Util.kt @@ -2,7 +2,6 @@ package taboolib.module.chat import net.md_5.bungee.api.ChatColor import taboolib.common.platform.function.warning -import taboolib.common.util.orNull import taboolib.common.util.t import kotlin.math.ceil @@ -53,23 +52,7 @@ fun List.uncolored() = map { it.uncolored() } * 获取颜色 */ fun String.parseToHexColor(): Int { - // HEX: #ffffff - if (startsWith('#')) { - return substring(1).toIntOrNull(16) ?: 0 - } - // RGB: 255,255,255 - if (contains(',')) { - return split(',').map { it.toIntOrNull() ?: 0 }.let { (r, g, b) -> (r shl 16) or (g shl 8) or b } - } - // RGB: 255-255-255 - if (contains('-')) { - return split('-').map { it.toIntOrNull() ?: 0 }.let { (r, g, b) -> (r shl 16) or (g shl 8) or b } - } - // NAMED: white - val knownColor = StandardColors.match(this) - if (knownColor.orNull()?.chatColor?.color != null) { - return knownColor.get().chatColor.color.rgb - } + HexColor.parseColor(this)?.let { return it } warning( """ $this 不是一个颜色。 diff --git a/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt b/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt new file mode 100644 index 000000000..85dfbf4e7 --- /dev/null +++ b/module/minecraft/minecraft-chat/src/test/kotlin/taboolib/module/chat/ColorParsingTest.kt @@ -0,0 +1,46 @@ +package taboolib.module.chat + +import net.md_5.bungee.api.ChatColor +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.awt.Color + +class ColorParsingTest { + + @Test + fun `strict color parser preserves black and leading zero colors`() { + assertEquals(0x000000, HexColor.parseColor("#000000")) + assertEquals(0x000001, HexColor.parseColor("#000001")) + assertEquals(0x00ff0a, HexColor.parseColor("#00ff0a")) + assertEquals(0xffffff, HexColor.parseColor("#FFFFFF")) + } + + @Test + fun `strict color parser rejects malformed hex and rgb`() { + listOf("#fff", "#00000", "#0000000", "#gggggg", "#", "##000000").forEach { + assertNull(HexColor.parseColor(it), it) + } + listOf("1,2", "1,2,3,4", "255,x,255", "256,0,0", "-1,0,0", "1--2-3", "1,,3").forEach { + assertNull(HexColor.parseColor(it), it) + } + } + + @Test + fun `strict color parser accepts variable width rgb components`() { + assertEquals(0x000000, HexColor.parseColor("0,0,0")) + assertEquals(0x0114ff, HexColor.parseColor("1,20,255")) + assertEquals(0xffffff, HexColor.parseColor("255-255-255")) + assertEquals(0x0114ff, HexColor.parseColor("1 - 20 - 255")) + } + + @Test + fun `hex translation preserves invalid expressions and parses valid rgb`() { + assertEquals("&{999,0,0}x", HexColor.translate("&{999,0,0}x")) + assertEquals("&{abc,def,ghi}x", HexColor.translate("&{abc,def,ghi}x")) + assertEquals("&{1,2", HexColor.translate("&{1,2")) + assertEquals("${ChatColor.of(Color(1, 20, 255))}x", HexColor.translate("&{1,20,255}x")) + assertEquals("${ChatColor.BLUE}x", HexColor.translate("&{BLUE}x")) + assertEquals("${ChatColor.WHITE}x", HexColor.translate("&{RESET}x")) + } +} diff --git a/module/minecraft/minecraft-i18n/build.gradle.kts b/module/minecraft/minecraft-i18n/build.gradle.kts index c33b879c1..82b5a4551 100644 --- a/module/minecraft/minecraft-i18n/build.gradle.kts +++ b/module/minecraft/minecraft-i18n/build.gradle.kts @@ -6,4 +6,8 @@ dependencies { compileOnly(project(":common-util")) compileOnly(project(":module:minecraft:minecraft-chat")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":common-util")) + testImplementation(project(":module:minecraft:minecraft-chat")) + testImplementation(project(":module:basic:basic-configuration")) } \ No newline at end of file diff --git a/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java new file mode 100644 index 000000000..17dd8f3ed --- /dev/null +++ b/module/minecraft/minecraft-i18n/src/main/java/taboolib/module/lang/SnapshotHashMap.java @@ -0,0 +1,365 @@ +package taboolib.module.lang; + +import java.util.AbstractCollection; +import java.util.AbstractSet; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * 保持 HashMap API 的无锁快照映射,读取固定快照,写入通过 CAS 一次替换。 + */ +final class SnapshotHashMap extends HashMap { + + private static final long serialVersionUID = 1L; + private final AtomicReference> snapshot; + + SnapshotHashMap() { + this(new HashMap<>()); + } + + SnapshotHashMap(Map source) { + snapshot = new AtomicReference<>(new HashMap<>(source)); + } + + void replaceWith(Map source) { + snapshot.set(new HashMap<>(source)); + } + + @Override + public int size() { + return snapshot.get().size(); + } + + @Override + public boolean isEmpty() { + return snapshot.get().isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return snapshot.get().containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return snapshot.get().containsValue(value); + } + + @Override + public V get(Object key) { + return snapshot.get().get(key); + } + + @Override + public V getOrDefault(Object key, V defaultValue) { + return snapshot.get().getOrDefault(key, defaultValue); + } + + @Override + public Set keySet() { + return new AbstractSet() { + @Override + public Iterator iterator() { + Iterator iterator = new HashMap<>(snapshot.get()).keySet().iterator(); + return new Iterator() { + private K current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public K next() { + current = iterator.next(); + return current; + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + return SnapshotHashMap.this.containsKey(value); + } + + @Override + public boolean remove(Object value) { + boolean present = SnapshotHashMap.this.containsKey(value); + SnapshotHashMap.this.remove(value); + return present; + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public Collection values() { + return new AbstractCollection() { + @Override + public Iterator iterator() { + Iterator> iterator = new HashMap<>(snapshot.get()).entrySet().iterator(); + return new Iterator() { + private Entry current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public V next() { + current = iterator.next(); + return current.getValue(); + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current.getKey(), current.getValue()); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + return SnapshotHashMap.this.containsValue(value); + } + + @Override + public boolean remove(Object value) { + for (Entry entry : snapshot.get().entrySet()) { + if (Objects.equals(entry.getValue(), value)) { + return SnapshotHashMap.this.remove(entry.getKey(), entry.getValue()); + } + } + return false; + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public Set> entrySet() { + return new AbstractSet>() { + @Override + public Iterator> iterator() { + Iterator> iterator = new HashMap<>(snapshot.get()).entrySet().iterator(); + return new Iterator>() { + private Entry current; + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Entry next() { + current = iterator.next(); + K key = current.getKey(); + return new Entry() { + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return SnapshotHashMap.this.get(key); + } + + @Override + public V setValue(V value) { + return SnapshotHashMap.this.put(key, value); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof Entry)) { + return false; + } + Entry entry = (Entry) other; + return Objects.equals(key, entry.getKey()) && Objects.equals(getValue(), entry.getValue()); + } + + @Override + public int hashCode() { + return Objects.hashCode(key) ^ Objects.hashCode(getValue()); + } + }; + } + + @Override + public void remove() { + SnapshotHashMap.this.remove(current.getKey(), current.getValue()); + } + }; + } + + @Override + public int size() { + return SnapshotHashMap.this.size(); + } + + @Override + public boolean contains(Object value) { + if (!(value instanceof Entry)) { + return false; + } + Entry entry = (Entry) value; + return SnapshotHashMap.this.containsKey(entry.getKey()) + && Objects.equals(SnapshotHashMap.this.get(entry.getKey()), entry.getValue()); + } + + @Override + public boolean remove(Object value) { + if (!(value instanceof Entry)) { + return false; + } + Entry entry = (Entry) value; + return SnapshotHashMap.this.remove(entry.getKey(), entry.getValue()); + } + + @Override + public void clear() { + SnapshotHashMap.this.clear(); + } + }; + } + + @Override + public void forEach(BiConsumer action) { + snapshot.get().forEach(action); + } + + @Override + public V put(K key, V value) { + return mutate(copy -> copy.put(key, value)); + } + + @Override + public void putAll(Map map) { + mutate(copy -> { + copy.putAll(map); + return null; + }); + } + + @Override + public V putIfAbsent(K key, V value) { + return mutate(copy -> copy.putIfAbsent(key, value)); + } + + @Override + public V remove(Object key) { + return mutate(copy -> copy.remove(key)); + } + + @Override + public boolean remove(Object key, Object value) { + return mutate(copy -> copy.remove(key, value)); + } + + @Override + public V replace(K key, V value) { + return mutate(copy -> copy.replace(key, value)); + } + + @Override + public boolean replace(K key, V oldValue, V newValue) { + return mutate(copy -> copy.replace(key, oldValue, newValue)); + } + + @Override + public void replaceAll(BiFunction function) { + mutate(copy -> { + copy.replaceAll(function); + return null; + }); + } + + @Override + public V computeIfAbsent(K key, Function mappingFunction) { + return mutate(copy -> copy.computeIfAbsent(key, mappingFunction)); + } + + @Override + public V computeIfPresent(K key, BiFunction remappingFunction) { + return mutate(copy -> copy.computeIfPresent(key, remappingFunction)); + } + + @Override + public V compute(K key, BiFunction remappingFunction) { + return mutate(copy -> copy.compute(key, remappingFunction)); + } + + @Override + public V merge(K key, V value, BiFunction remappingFunction) { + return mutate(copy -> copy.merge(key, value, remappingFunction)); + } + + @Override + public void clear() { + snapshot.set(new HashMap<>()); + } + + @Override + public Object clone() { + return new HashMap<>(snapshot.get()); + } + + @Override + public boolean equals(Object other) { + return snapshot.get().equals(other); + } + + @Override + public int hashCode() { + return snapshot.get().hashCode(); + } + + @Override + public String toString() { + return snapshot.get().toString(); + } + + private R mutate(Function, R> operation) { + while (true) { + HashMap current = snapshot.get(); + HashMap updated = new HashMap<>(current); + R result = operation.apply(updated); + if (snapshot.compareAndSet(current, updated)) { + return result; + } + } + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt index 7d9886ebe..dff7686e8 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/Language.kt @@ -45,7 +45,7 @@ object Language : OpenListener { val textTransfer = ArrayList() /** 语言文件缓存 */ - val languageFile = HashMap() + val languageFile: HashMap = SnapshotHashMap() /** 语言文件代码 */ val languageCode = HashSet() @@ -94,9 +94,9 @@ object Language : OpenListener { /** 添加新的语言文件 */ fun addLanguage(vararg code: String) { - languageCode += code + val changed = code.fold(false) { result, value -> languageCode.add(value) || result } // 如果已经完成了首次加载,则立刻重载语言文件 - if (isFirstLoaded) { + if (changed && isFirstLoaded) { reload() } } @@ -135,8 +135,8 @@ object Language : OpenListener { } // 加载语言文件 isFirstLoaded = true - languageFile.clear() - languageFile.putAll(ResourceReader(Language::class.java).files) + val loadedFiles = ResourceReader(Language::class.java).files + replaceLanguageFiles(languageFile, loadedFiles) } override fun call(name: String, data: Array?): OpenResult { @@ -147,4 +147,14 @@ object Language : OpenListener { else -> OpenResult.failed() } } -} \ No newline at end of file +} + +@JvmSynthetic +internal fun replaceLanguageFiles(target: HashMap, loaded: Map) { + if (target is SnapshotHashMap) { + target.replaceWith(loaded) + } else { + target.clear() + target.putAll(loaded) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt index 0cccb1408..c50fbcb2c 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/LanguageFile.kt @@ -9,4 +9,12 @@ import java.io.File * @author sky * @since 2021/6/18 11:04 下午 */ -class LanguageFile(val file: File, val nodes: HashMap) \ No newline at end of file +class LanguageFile(val file: File, nodes: HashMap) { + + val nodes: HashMap = SnapshotHashMap(nodes) + + @JvmSynthetic + internal fun replaceNodes(nodes: HashMap) { + (this.nodes as SnapshotHashMap).replaceWith(nodes) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt index 82aa8f06d..85b8e01e0 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/ResourceReader.kt @@ -31,7 +31,18 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { init { Language.languageCode.forEach { code -> - val fileName = runningResourcesInJar.keys.first { it.startsWith("${Language.path}/$code") } + val fileName = findLanguageResource(runningResourcesInJar.keys, Language.path, code) { + Configuration.getTypeFromExtensionOrNull(it) != null + } + if (fileName == null) { + warning( + """ + 未能找到语言文件: $code + Missing language file: $code + """.t() + ) + return@forEach + } val bytes = runningResourcesInJar[fileName] if (bytes != null) { val nodes = HashMap() @@ -64,18 +75,18 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { // 文件变动监听 if (Language.enableFileWatcher) { FileWatcher.INSTANCE.addSimpleListener(file) { _ -> - it.nodes.clear() - loadNodes(sourceFile, it.nodes, code) - loadNodes(Configuration.loadFromFile(file), it.nodes, code) + val reloaded = HashMap() + loadNodes(sourceFile, reloaded, code) + loadNodes(Configuration.loadFromFile(file), reloaded, code) + it.replaceNodes(reloaded) } } } } else { - val file = "$code.${fileName.substringAfterLast('.')}" warning( """ - 未能找到语言文件: $file - Missing language file: $file + 未能读取语言文件: $fileName + Unable to read language file: $fileName """.t() ) } @@ -174,4 +185,19 @@ class ResourceReader(val clazz: Class<*>, val migrate: Boolean = true) { file.appendText("\n${append.joinToString("\n")}") } } -} \ No newline at end of file +} + +@JvmSynthetic +internal fun findLanguageResource( + resources: Set, + path: String, + code: String, + isSupportedExtension: (String) -> Boolean = { true }, +): String? { + val prefix = path.trimEnd('/') + '/' + return resources.firstOrNull { resource -> + resource.startsWith(prefix) + && resource.substringAfterLast('/').substringBeforeLast('.') == code + && isSupportedExtension(resource.substringAfterLast('.', "")) + } +} diff --git a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt index f7903fa30..c52be91f1 100644 --- a/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt +++ b/module/minecraft/minecraft-i18n/src/main/kotlin/taboolib/module/lang/TypeJson.kt @@ -4,6 +4,7 @@ import taboolib.common.platform.ProxyCommandSender import taboolib.common.util.VariableReader import taboolib.common.util.asList import taboolib.common.util.replaceWithOrder +import taboolib.library.configuration.ConfigurationSection import taboolib.module.chat.* /** @@ -21,9 +22,14 @@ class TypeJson : Type { override fun init(source: Map) { text = source["text"]?.asList() - try { - jsonArgs.addAll((source["args"] as List<*>).map { (it as Map<*, *>).map { (k, v) -> k.toString() to v!! }.toMap() }) - } catch (_: ClassCastException) { + jsonArgs.clear() + val args = normalizeJsonValue(source["args"]) as? List<*> ?: return + args.forEach { value -> + val map = value as? Map<*, *> ?: return@forEach + jsonArgs += map.entries.mapNotNull { (key, entryValue) -> + val normalized = normalizeJsonValue(entryValue) ?: return@mapNotNull null + key?.toString()?.let { it to normalized } + }.toMap() } } @@ -53,16 +59,17 @@ class TypeJson : Type { // 显示文字 val showText = formated(part.text, sender, *args) val showType = formated(extra["type"].toString(), sender, *args) + val (typeName, typeArgs) = parseJsonType(showType) when { // 快捷键 - showType == "keybind" -> appendKeybind(showText) + typeName == "keybind" -> appendKeybind(showText) // 选择器 - showType == "selector" -> appendSelector(showText) + typeName == "selector" -> appendSelector(showText) // 语言 // text: '[commands.drop.success.single]' // args: // - type: translate:1:Stone - showType == "translate" -> appendTranslation(showText, *showType.substringAfter(':').split(':').toTypedArray()) + typeName == "translate" -> appendTranslation(showText, *typeArgs.toTypedArray()) // 分数 showType == "score" -> appendScore(showText.substringBefore(':'), showText.substringAfter(':')) // 渐变颜色文本 @@ -77,7 +84,10 @@ class TypeJson : Type { } // 附加信息 if (extra.containsKey("hover")) { - hoverText(formated(extra["hover"].toString(), sender, *args)) + when (val hover = extra["hover"]) { + is List<*> -> hoverText(hover.map { formated(it.toString(), sender, *args) }) + else -> hoverText(formated(hover.toString(), sender, *args)) + } } if (extra.containsKey("command")) { clickRunCommand(formated(extra["command"].toString(), sender, *args)) @@ -113,3 +123,20 @@ class TypeJson : Type { private val parser = VariableReader("[", "]") } } + +@JvmSynthetic +internal fun parseJsonType(value: String): Pair> { + val parts = value.split(':') + return parts.firstOrNull().orEmpty() to parts.drop(1) +} + +@JvmSynthetic +internal fun normalizeJsonValue(value: Any?): Any? { + return when (value) { + is ConfigurationSection -> value.getValues(false).entries.associate { (key, entryValue) -> key to normalizeJsonValue(entryValue) } + is Map<*, *> -> value.entries.associate { (key, entryValue) -> key.toString() to normalizeJsonValue(entryValue) } + is Iterable<*> -> value.map(::normalizeJsonValue) + is Array<*> -> value.map(::normalizeJsonValue) + else -> value + } +} diff --git a/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt b/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt new file mode 100644 index 000000000..9b01b1a4e --- /dev/null +++ b/module/minecraft/minecraft-i18n/src/test/kotlin/taboolib/module/lang/LanguageBoundaryTest.kt @@ -0,0 +1,94 @@ +package taboolib.module.lang + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +class LanguageBoundaryTest { + + @Test + fun `json normalization preserves nested value types and nulls`() { + val normalized = normalizeJsonValue( + mapOf( + "object" to mapOf("enabled" to true, "count" to 3), + "array" to listOf(1, false, mapOf("nested" to 2.5)), + "nullable" to null, + ) + ) as Map<*, *> + + val objectValue = normalized["object"] as Map<*, *> + val arrayValue = normalized["array"] as List<*> + assertSame(true, objectValue["enabled"]) + assertEquals(3, objectValue["count"]) + assertEquals(1, arrayValue[0]) + assertSame(false, arrayValue[1]) + assertEquals(2.5, (arrayValue[2] as Map<*, *>)["nested"]) + assertTrue(normalized.containsKey("nullable")) + assertNull(normalized["nullable"]) + } + + @Test + fun `type json reinitialization replaces old arguments`() { + val type = TypeJson() + type.init(mapOf("text" to "[value]", "args" to listOf(mapOf("type" to "text", "old" to 1)))) + type.init(mapOf("text" to "[value]", "args" to listOf(mapOf("type" to "translate:1:Stone", "new" to true)))) + + assertEquals(1, type.jsonArgs.size) + assertFalse(type.jsonArgs.single().containsKey("old")) + assertSame(true, type.jsonArgs.single()["new"]) + } + + @Test + fun `json type parser separates translate arguments without prefix matches`() { + assertEquals("translate" to emptyList(), parseJsonType("translate")) + assertEquals("translate" to listOf("1", "Stone"), parseJsonType("translate:1:Stone")) + assertEquals("translateFoo" to emptyList(), parseJsonType("translateFoo")) + } + + @Test + fun `language resource lookup uses exact code and supported extension`() { + val resources = setOf("lang/en_US.yml", "lang/en_GB.json", "lang/en.txt", "other/en.yml") + assertEquals("lang/en_US.yml", findLanguageResource(resources, "lang", "en_US") { it == "yml" || it == "json" }) + assertNull(findLanguageResource(resources, "lang", "en") { it == "yml" || it == "json" }) + assertEquals("lang/en_GB.json", findLanguageResource(resources, "lang", "en_GB") { it == "yml" || it == "json" }) + } + + @Test + fun `language cache replacement preserves public map reference`() { + val originalFile = LanguageFile(File("old.yml"), hashMapOf()) + val replacementFile = LanguageFile(File("new.yml"), hashMapOf()) + val target: HashMap = SnapshotHashMap(mapOf("old" to originalFile)) + val exposed = target + val keys = target.keys + + replaceLanguageFiles(target, mapOf("new" to replacementFile)) + + assertSame(exposed, target) + assertFalse(target.containsKey("old")) + assertTrue(keys.contains("new")) + assertSame(replacementFile, target["new"]) + target.entries.single().setValue(originalFile) + assertSame(originalFile, target["new"]) + target["new"] = replacementFile + assertTrue(target.values.remove(replacementFile)) + assertTrue(target.isEmpty()) + } + + @Test + fun `language file replaces complete node snapshot`() { + val original = hashMapOf("old" to TypeText("old")) + val languageFile = LanguageFile(File("unused.yml"), original) + val replacement = hashMapOf("new" to TypeText("new")) + val exposed = languageFile.nodes + + languageFile.replaceNodes(replacement) + + assertSame(exposed, languageFile.nodes) + assertFalse(languageFile.nodes.containsKey("old")) + assertTrue(languageFile.nodes.containsKey("new")) + } +}