From 23f5f80baaa47e75be3ed98be53235e993c48f45 Mon Sep 17 00:00:00 2001 From: Somikyy Date: Thu, 23 Jul 2026 12:17:17 +0200 Subject: [PATCH 1/3] Phase 2: real-time MT, chat translation, colored args, YACL settings UI - remote/: Google free MT (default) + LibreTranslate backends behind a polite queue (dedup, rate spacing, persistent daily budget, back-off); results are placeholder-validated, cached, and the render memo is flushed so visible text updates live - ChatComponentMixin: server system messages (NPC dialogues) on by default, player chat opt-in; interactive messages are skipped - Normalizer v2: numbers keep their sign and color codes as arguments, surrounding color restored after substitution; dictionary keys drop the + sign accordingly - ui/: YACL config screen (bundled jar-in-jar) with Mod Menu entry and O keybind - general/online categories plus a string editor over the 50 last-seen templates; edits persist as top-priority overrides - OverrideProvider: user corrections beat dictionary and MT cache Co-Authored-By: Claude Opus 4.8 --- build.gradle | 16 ++ gradle.properties | 2 + .../SkyblockTranslatorClient.java | 21 ++- .../skyblocktranslator/config/ModConfig.java | 14 +- .../mixin/ChatComponentMixin.java | 54 ++++++ .../pipeline/Normalizer.java | 131 ++++++++++++--- .../pipeline/TranslationEngine.java | 132 +++++++++++++-- .../providers/OverrideProvider.java | 71 ++++++++ .../remote/GoogleFreeTranslator.java | 59 +++++++ .../remote/LibreTranslator.java | 56 ++++++ .../remote/RemoteQueue.java | 159 ++++++++++++++++++ .../remote/RemoteTranslator.java | 17 ++ .../skyblocktranslator/ui/ConfigScreens.java | 152 +++++++++++++++++ .../ui/ModMenuIntegration.java | 12 ++ .../skyblock-translator/dict/ru_ru/stats.json | 36 ++-- .../skyblock-translator/lang/en_us.json | 40 +++++ .../skyblock-translator/lang/ru_ru.json | 40 +++++ src/main/resources/fabric.mod.json | 9 +- .../resources/skyblock-translator.mixins.json | 3 +- .../pipeline/NormalizerTest.java | 59 +++++-- 20 files changed, 1007 insertions(+), 76 deletions(-) create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/mixin/ChatComponentMixin.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/providers/OverrideProvider.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/remote/GoogleFreeTranslator.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/remote/LibreTranslator.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteQueue.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteTranslator.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/ui/ModMenuIntegration.java create mode 100644 src/main/resources/assets/skyblock-translator/lang/en_us.json create mode 100644 src/main/resources/assets/skyblock-translator/lang/ru_ru.json diff --git a/build.gradle b/build.gradle index db88df3..c00c81d 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,14 @@ repositories { name = 'DevAuth' url = 'https://pkgs.dev.azure.com/djtheredstoner/DevAuth/_packaging/public/maven/v1' } + maven { + name = 'Xander' + url = 'https://maven.isxander.dev/releases' + } + maven { + name = 'Terraformers' + url = 'https://maven.terraformersmc.com/releases/' + } } loom { @@ -38,6 +46,14 @@ dependencies { implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + // Config UI library, bundled into our jar (jar-in-jar) + implementation "dev.isxander:yet-another-config-lib:${project.yacl_version}" + include "dev.isxander:yet-another-config-lib:${project.yacl_version}" + + // Mod Menu integration (optional at runtime; present in dev for testing) + compileOnly "com.terraformersmc:modmenu:${project.modmenu_version}" + runtimeOnly "com.terraformersmc:modmenu:${project.modmenu_version}" + // Dev-environment only: real-account login for runClient (not bundled in the jar) runtimeOnly "me.djtheredstoner:DevAuth-fabric:1.2.2" diff --git a/gradle.properties b/gradle.properties index 77ecdc2..1058c42 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,3 +17,5 @@ maven_group=dev.laybalt # Dependencies fabric_api_version=0.155.2+26.2 +yacl_version=3.9.6+26.2-fabric +modmenu_version=20.0.1 diff --git a/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java b/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java index 32b3428..36d13e9 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java @@ -1,12 +1,19 @@ package dev.laybalt.skyblocktranslator; +import com.mojang.blaze3d.platform.InputConstants; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; +import org.lwjgl.glfw.GLFW; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import dev.laybalt.skyblocktranslator.config.ModConfig; import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; +import dev.laybalt.skyblocktranslator.ui.ConfigScreens; public class SkyblockTranslatorClient implements ClientModInitializer { public static final String MOD_ID = "skyblock-translator"; @@ -16,7 +23,19 @@ public class SkyblockTranslatorClient implements ClientModInitializer { public void onInitializeClient() { ModConfig config = ModConfig.get(); TranslationEngine.init(); - ClientLifecycleEvents.CLIENT_STOPPING.register(client -> TranslationEngine.get().cache().save()); + + KeyMapping openConfig = KeyMappingHelper.registerKeyMapping(new KeyMapping( + "key.skyblock-translator.config", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_O, + KeyMapping.Category.register(Identifier.fromNamespaceAndPath(MOD_ID, "main")))); + ClientTickEvents.END_CLIENT_TICK.register(client -> { + while (openConfig.consumeClick()) { + client.setScreenAndShow(ConfigScreens.create(null)); + } + }); + + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> TranslationEngine.get().shutdown()); LOGGER.info("SkyBlock Translator initialized (language={}, enabled={})", config.language, config.enabled); } } diff --git a/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java b/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java index 964a77d..2a43cd2 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java @@ -28,8 +28,18 @@ public final class ModConfig { public boolean onlyOnHypixel = true; public boolean translateItems = true; public boolean translateMenus = true; - /** Reserved for Phase 2 (chat / NPC dialogue translation). */ - public boolean translateChat = false; + /** NPC dialogues and other server system messages in chat. */ + public boolean translateDialogs = true; + /** Messages written by other players; off by default. */ + public boolean translatePlayerChat = false; + /** Send unknown strings to an online translator and cache the results. */ + public boolean translateOnline = true; + /** "google" (free, zero setup) or "libretranslate" (own instance below). */ + public String onlineProvider = "google"; + public String libreTranslateUrl = ""; + public String libreTranslateApiKey = ""; + /** Max online translation requests per day. */ + public int dailyOnlineBudget = 2000; /** Append unknown templates to config/skyblock-translator/untranslated-.txt. */ public boolean dumpUntranslated = true; /** Reserved for Phase 3 (premium cloud translation). */ diff --git a/src/main/java/dev/laybalt/skyblocktranslator/mixin/ChatComponentMixin.java b/src/main/java/dev/laybalt/skyblocktranslator/mixin/ChatComponentMixin.java new file mode 100644 index 0000000..a0b8a55 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/mixin/ChatComponentMixin.java @@ -0,0 +1,54 @@ +package dev.laybalt.skyblocktranslator.mixin; + +import net.minecraft.client.gui.components.ChatComponent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.detect.HypixelDetector; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** + * Chat translation. NPC dialogues on SkyBlock arrive as server system messages, + * so {@code addServerSystemMessage} covers dialogues, quest text and + * announcements; player messages are a separate (default-off) toggle. + * + *

Messages carrying click/hover events are left untouched for now: our + * translated component is a flat literal and would drop the interaction. + */ +@Mixin(ChatComponent.class) +public abstract class ChatComponentMixin { + @ModifyVariable(method = "addServerSystemMessage", at = @At("HEAD"), argsOnly = true) + private Component skyblockTranslator$translateSystem(Component message) { + if (!ModConfig.get().translateDialogs || !HypixelDetector.isTranslationActive() + || hasInteraction(message)) { + return message; + } + return TranslationEngine.get().translate(message); + } + + @ModifyVariable(method = "addPlayerMessage", at = @At("HEAD"), argsOnly = true) + private Component skyblockTranslator$translatePlayer(Component message) { + if (!ModConfig.get().translatePlayerChat || !HypixelDetector.isTranslationActive() + || hasInteraction(message)) { + return message; + } + return TranslationEngine.get().translate(message); + } + + private static boolean hasInteraction(Component component) { + Style style = component.getStyle(); + if (style.getClickEvent() != null || style.getHoverEvent() != null) { + return true; + } + for (Component sibling : component.getSiblings()) { + if (hasInteraction(sibling)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java index 04fe9bb..faf6f0d 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java @@ -2,48 +2,109 @@ import java.util.ArrayList; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Turns concrete strings into placeholder templates and back. * - *

{@code "Damage: +1,024"} becomes template {@code "Damage: +{0}"} with args - * {@code ["1,024"]}. One dictionary entry then covers every value the game can - * show, which is what makes the dictionary + cache approach viable: SkyBlock - * text is highly repetitive once numbers are factored out. + *

{@code "§7Damage: §c+1,024"} becomes template {@code "Damage: {0}"} with the + * argument keeping its sign and color codes ({@code "§c+1,024"}). One dictionary entry then + * covers every value the game can show, and numbers stay colored after + * substitution — SkyBlock loves coloring the value differently from the label. + * + *

Template keys are always §-stripped; the input may be a plain string or a + * legacy string with codes. */ public final class Normalizer { - /** Numbers incl. thousands separators, decimals and a trailing %: 1,024 / 3.5 / 100% */ - private static final Pattern NUMBER = Pattern.compile("\\d[\\d,.]*%?"); + private static final char SECTION = '§'; private Normalizer() { } - public record Template(String key, List args) { + /** + * @param raw the exact matched substring, codes included ("§c1,024") + * @param after codes re-applied after the value so the following text gets its + * original color back; empty when the value had no codes of its own + */ + public record Arg(String raw, String after) { + } + + public record Template(String key, List args) { + } + + /** Extracts numbers (with their adjacent §-codes) into {0}, {1}, ... placeholders. */ + public static Template normalize(String legacy) { + StringBuilder key = new StringBuilder(legacy.length()); + List args = null; + + // Codes seen since the last plain char; they color whatever comes next. + StringBuilder pending = new StringBuilder(); + // Codes that were active before the pending run — the "surrounding" style. + String activeBefore = ""; + StringBuilder active = new StringBuilder(); + + int i = 0; + while (i < legacy.length()) { + char c = legacy.charAt(i); + if (c == SECTION && i + 1 < legacy.length()) { + if (pending.isEmpty()) { + activeBefore = active.toString(); + } + pending.append(c).append(legacy.charAt(i + 1)); + i += 2; + continue; + } + boolean signedNumber = (c == '+' || c == '-') + && i + 1 < legacy.length() && Character.isDigit(legacy.charAt(i + 1)); + if (Character.isDigit(c) || signedNumber) { + int end = i + 1; + while (end < legacy.length() && (Character.isDigit(legacy.charAt(end)) + || legacy.charAt(end) == ',' || legacy.charAt(end) == '.')) { + end++; + } + // trailing . or , belongs to the sentence, not the number + while (legacy.charAt(end - 1) == ',' || legacy.charAt(end - 1) == '.') { + end--; + } + if (end < legacy.length() && legacy.charAt(end) == '%') { + end++; + } + if (args == null) { + args = new ArrayList<>(); + } + String number = legacy.substring(i, end); + String after = pending.isEmpty() ? "" : activeBefore; + key.append('{').append(args.size()).append('}'); + args.add(new Arg(pending + number, after)); + applyCodes(active, pending); + pending.setLength(0); + i = end; + continue; + } + applyCodes(active, pending); + pending.setLength(0); + key.append(c); + i++; + } + return new Template(key.toString(), args == null ? List.of() : List.copyOf(args)); } - /** Extracts numbers into {0}, {1}, ... placeholders. */ - public static Template normalize(String plain) { - Matcher m = NUMBER.matcher(plain); - if (!m.find()) { - return new Template(plain, List.of()); + /** Legacy semantics: a color code resets the run, format codes stack, §r clears. */ + private static void applyCodes(StringBuilder active, CharSequence codes) { + for (int i = 0; i + 1 < codes.length(); i += 2) { + char code = Character.toLowerCase(codes.charAt(i + 1)); + if (code == 'r') { + active.setLength(0); + } else if ((code >= '0' && code <= '9') || (code >= 'a' && code <= 'f')) { + active.setLength(0); + active.append(SECTION).append(code); + } else { + active.append(SECTION).append(code); + } } - List args = new ArrayList<>(); - StringBuilder key = new StringBuilder(); - int last = 0; - do { - key.append(plain, last, m.start()); - key.append('{').append(args.size()).append('}'); - args.add(m.group()); - last = m.end(); - } while (m.find()); - key.append(plain, last, plain.length()); - return new Template(key.toString(), List.copyOf(args)); } /** Substitutes {n} placeholders in a translated template with the original values. */ - public static String restore(String translated, List args) { + public static String restore(String translated, List args) { if (args.isEmpty()) { return translated; } @@ -56,7 +117,8 @@ public static String restore(String translated, List args) { try { int idx = Integer.parseInt(translated, i + 1, close, 10); if (idx >= 0 && idx < args.size()) { - out.append(args.get(idx)); + Arg arg = args.get(idx); + out.append(arg.raw()).append(arg.after()); i = close; continue; } @@ -69,4 +131,19 @@ public static String restore(String translated, List args) { } return out.toString(); } + + /** Number of distinct placeholders a template refers to; used to validate MT output. */ + public static int placeholderCount(String template) { + int count = 0; + for (int i = 0; i < template.length() - 1; i++) { + if (template.charAt(i) == '{' && Character.isDigit(template.charAt(i + 1))) { + int close = template.indexOf('}', i); + if (close > i) { + count++; + i = close; + } + } + } + return count; + } } diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java index 51a4135..a078944 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java @@ -13,31 +13,42 @@ import java.util.concurrent.ConcurrentHashMap; import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient; import dev.laybalt.skyblocktranslator.config.ModConfig; import dev.laybalt.skyblocktranslator.providers.DictionaryProvider; import dev.laybalt.skyblocktranslator.providers.LocalCacheProvider; +import dev.laybalt.skyblocktranslator.providers.OverrideProvider; import dev.laybalt.skyblocktranslator.providers.TranslationProvider; +import dev.laybalt.skyblocktranslator.remote.GoogleFreeTranslator; +import dev.laybalt.skyblocktranslator.remote.LibreTranslator; +import dev.laybalt.skyblocktranslator.remote.RemoteQueue; +import dev.laybalt.skyblocktranslator.remote.RemoteTranslator; /** * Render-side translation: takes the {@link Component} that is about to be drawn - * and returns a translated replacement, or the original when nothing matches. + * and returns a translated replacement, or the original when nothing matches yet. * - *

Resolution order: bundled dictionary → local cache (→ remote providers in - * later phases). Results are memoized per flattened legacy string because - * tooltips are rebuilt every frame. Unknown templates are appended to an - * {@code untranslated-.txt} dump, which is how the dictionaries get grown. + *

Resolution order: user overrides → bundled dictionary → local cache. Misses + * are queued for online translation; when a result lands in the cache the memo is + * flushed, and because tooltips are rebuilt every frame the text updates live. + * Unknown templates are also appended to {@code untranslated-.txt}, which + * is how the bundled dictionaries get grown. */ public final class TranslationEngine { private static final int MEMO_LIMIT = 4096; + private static final int RECENT_LIMIT = 200; - private static TranslationEngine instance; + private static volatile TranslationEngine instance; private final List providers; + private final OverrideProvider overrides; private final LocalCacheProvider cache; + @Nullable + private final RemoteQueue remoteQueue; - /** Legacy string -> rendered component. Guarded by itself (render thread + safety). */ + /** Legacy string -> rendered component. Guarded by itself. */ private final Map memo = new LinkedHashMap<>(256, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { @@ -45,12 +56,25 @@ protected boolean removeEldestEntry(Map.Entry eldest) { } }; + /** Recently seen templates (translation or null) — feeds the in-game string editor. */ + private final Map recentSeen = new LinkedHashMap<>(64, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > RECENT_LIMIT; + } + }; + + /** Templates the MT provider mangled (placeholder mismatch) — don't retry, don't cache. */ + private final Set rejected = ConcurrentHashMap.newKeySet(); + private final Set dumped = ConcurrentHashMap.newKeySet(); private final Path dumpFile; - private TranslationEngine(String language) { + private TranslationEngine(ModConfig config) { + String language = config.language; + this.overrides = new OverrideProvider(ModConfig.directory(), language); this.cache = new LocalCacheProvider(ModConfig.directory(), language); - this.providers = List.of(new DictionaryProvider(language), cache); + this.providers = List.of(overrides, new DictionaryProvider(language), cache); this.dumpFile = ModConfig.directory().resolve("untranslated-" + language + ".txt"); if (Files.exists(dumpFile)) { try { @@ -59,10 +83,36 @@ private TranslationEngine(String language) { SkyblockTranslatorClient.LOGGER.warn("Could not read {}", dumpFile, e); } } + this.remoteQueue = config.translateOnline + ? new RemoteQueue(createTranslator(config), mtLang(language), config.dailyOnlineBudget, + ModConfig.directory(), this::onRemoteResult) + : null; + } + + private static RemoteTranslator createTranslator(ModConfig config) { + if ("libretranslate".equals(config.onlineProvider) && !config.libreTranslateUrl.isBlank()) { + return new LibreTranslator(config.libreTranslateUrl, config.libreTranslateApiKey); + } + return new GoogleFreeTranslator(); + } + + /** "ru_ru" -> "ru" — MT services want plain ISO codes. */ + private static String mtLang(String language) { + int idx = language.indexOf('_'); + return idx > 0 ? language.substring(0, idx) : language; } public static void init() { - instance = new TranslationEngine(ModConfig.get().language); + instance = new TranslationEngine(ModConfig.get()); + } + + /** Recreates the engine after config changes (language, provider, toggles). */ + public static synchronized void reload() { + TranslationEngine old = instance; + if (old != null) { + old.shutdown(); + } + instance = new TranslationEngine(ModConfig.get()); } public static TranslationEngine get() { @@ -72,10 +122,41 @@ public static TranslationEngine get() { return instance; } + public OverrideProvider overrides() { + return overrides; + } + public LocalCacheProvider cache() { return cache; } + /** Newest-first snapshot of recently seen templates for the string editor UI. */ + public List> recentSeenSnapshot() { + synchronized (recentSeen) { + List> list = new ArrayList<>(recentSeen.size()); + for (Map.Entry e : recentSeen.entrySet()) { + list.add(Map.entry(e.getKey(), e.getValue() == null ? "" : e.getValue())); + } + java.util.Collections.reverse(list); + return list; + } + } + + public void shutdown() { + if (remoteQueue != null) { + remoteQueue.shutdown(); + } + cache.save(); + overrides.save(); + } + + /** Drops memoized results so edited/new translations take effect immediately. */ + public void flushMemo() { + synchronized (memo) { + memo.clear(); + } + } + /** Translates one component; returns the original instance when there is no translation. */ public Component translate(Component original) { String legacy = LegacyText.toLegacy(original); @@ -116,18 +197,47 @@ private Component compute(String legacy, Component original) { if (plain.isBlank()) { return original; } - Normalizer.Template template = Normalizer.normalize(plain); + Normalizer.Template template = Normalizer.normalize(legacy); for (TranslationProvider provider : providers) { String translation = provider.lookup(template.key()); if (translation != null) { + remember(template.key(), translation); String restored = Normalizer.restore(translation, template.args()); return Component.literal(LegacyText.leadingCodes(legacy) + restored); } } + remember(template.key(), null); recordMissing(template.key()); + if (remoteQueue != null && !rejected.contains(template.key()) && hasLetters(template.key())) { + remoteQueue.submit(template.key()); + } return original; } + private int remoteResults; + + /** Called from the remote worker thread when an online translation arrives. */ + private void onRemoteResult(String templateKey, String translation) { + if (Normalizer.placeholderCount(translation) != Normalizer.placeholderCount(templateKey)) { + rejected.add(templateKey); + SkyblockTranslatorClient.LOGGER.debug("MT mangled placeholders, rejected: {}", templateKey); + return; + } + cache.put(templateKey, translation); + if (++remoteResults % 20 == 0) { + cache.save(); // don't lose a session's worth of MT on a crash + } + remember(templateKey, translation); + flushMemo(); + } + + private void remember(String templateKey, @Nullable String translation) { + synchronized (recentSeen) { + recentSeen.remove(templateKey); + recentSeen.put(templateKey, translation); + } + } + private void recordMissing(String templateKey) { if (!ModConfig.get().dumpUntranslated || !hasLetters(templateKey) || !dumped.add(templateKey)) { return; diff --git a/src/main/java/dev/laybalt/skyblocktranslator/providers/OverrideProvider.java b/src/main/java/dev/laybalt/skyblocktranslator/providers/OverrideProvider.java new file mode 100644 index 0000000..03265c0 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/providers/OverrideProvider.java @@ -0,0 +1,71 @@ +package dev.laybalt.skyblocktranslator.providers; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import org.jetbrains.annotations.Nullable; + +import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient; + +/** + * User-defined translations with the highest priority — the in-game string + * editor writes here, and hand edits to the JSON file work too. Beats both the + * bundled dictionary and the MT cache, so a bad machine translation can always + * be corrected permanently. + */ +public final class OverrideProvider implements TranslationProvider { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + private static final TypeToken> MAP_TYPE = new TypeToken<>() { + }; + + private final Path file; + private final Map entries = new LinkedHashMap<>(); + + public OverrideProvider(Path configDir, String language) { + this.file = configDir.resolve("overrides-" + language + ".json"); + if (Files.exists(file)) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + Map loaded = GSON.fromJson(reader, MAP_TYPE); + if (loaded != null) { + entries.putAll(loaded); + } + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to load overrides {}", file, e); + } + } + } + + @Override + @Nullable + public synchronized String lookup(String templateKey) { + return entries.get(templateKey); + } + + public synchronized void put(String templateKey, String translation) { + if (translation == null || translation.isBlank()) { + entries.remove(templateKey); + } else { + entries.put(templateKey, translation); + } + } + + public synchronized void save() { + try { + Files.createDirectories(file.getParent()); + try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(entries, writer); + } + } catch (IOException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to save overrides {}", file, e); + } + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/remote/GoogleFreeTranslator.java b/src/main/java/dev/laybalt/skyblocktranslator/remote/GoogleFreeTranslator.java new file mode 100644 index 0000000..b9d68d1 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/remote/GoogleFreeTranslator.java @@ -0,0 +1,59 @@ +package dev.laybalt.skyblocktranslator.remote; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +/** + * Free machine translation through Google's public "gtx" endpoint. + * + *

No API key, but also no SLA — this is exactly the "free tier may hiccup" + * backend. The queue keeps request rates polite; on HTTP errors the queue backs + * off. The premium provider will replace this with an LLM service. + */ +public final class GoogleFreeTranslator implements RemoteTranslator { + private static final String ENDPOINT = "https://translate.googleapis.com/translate_a/single"; + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + @Override + public String translate(String text, String targetLang) throws IOException, InterruptedException { + String url = ENDPOINT + "?client=gtx&sl=en&tl=" + targetLang + "&dt=t&q=" + + URLEncoder.encode(text, StandardCharsets.UTF_8); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("User-Agent", "Mozilla/5.0") + .timeout(Duration.ofSeconds(15)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + " from translate endpoint"); + } + // Response shape: [[["перевод","original",...],["...","...",...]],...] + JsonArray segments = JsonParser.parseString(response.body()).getAsJsonArray().get(0).getAsJsonArray(); + StringBuilder out = new StringBuilder(); + for (JsonElement segment : segments) { + JsonElement translated = segment.getAsJsonArray().get(0); + if (!translated.isJsonNull()) { + out.append(translated.getAsString()); + } + } + return out.toString(); + } + + @Override + public String name() { + return "google"; + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/remote/LibreTranslator.java b/src/main/java/dev/laybalt/skyblocktranslator/remote/LibreTranslator.java new file mode 100644 index 0000000..641aa1c --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/remote/LibreTranslator.java @@ -0,0 +1,56 @@ +package dev.laybalt.skyblocktranslator.remote; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * LibreTranslate backend for self-hosters or users with an instance/API key. + * Configure the instance URL (and optional key) in the mod settings. + */ +public final class LibreTranslator implements RemoteTranslator { + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final String baseUrl; + private final String apiKey; + + public LibreTranslator(String baseUrl, String apiKey) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.apiKey = apiKey; + } + + @Override + public String translate(String text, String targetLang) throws IOException, InterruptedException { + JsonObject payload = new JsonObject(); + payload.addProperty("q", text); + payload.addProperty("source", "en"); + payload.addProperty("target", targetLang); + payload.addProperty("format", "text"); + if (!apiKey.isBlank()) { + payload.addProperty("api_key", apiKey); + } + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/translate")) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(15)) + .POST(HttpRequest.BodyPublishers.ofString(payload.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + " from LibreTranslate"); + } + return JsonParser.parseString(response.body()).getAsJsonObject().get("translatedText").getAsString(); + } + + @Override + public String name() { + return "libretranslate"; + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteQueue.java b/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteQueue.java new file mode 100644 index 0000000..d5f2bc3 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteQueue.java @@ -0,0 +1,159 @@ +package dev.laybalt.skyblocktranslator.remote; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.BiConsumer; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient; + +/** + * Single background worker feeding templates to a {@link RemoteTranslator}. + * + *

Politeness rules: requests are deduplicated, spaced by a minimum interval, + * capped by a persistent per-day budget, and errors trigger a cool-down instead + * of hammering the endpoint. Results are handed back to the engine on the worker + * thread; the engine takes care of thread safety. + */ +public final class RemoteQueue { + private static final long MIN_INTERVAL_MS = 350; + private static final long ERROR_BACKOFF_MS = 60_000; + + private final RemoteTranslator translator; + private final String targetLang; + private final int dailyBudget; + private final Path statsFile; + private final BiConsumer onResult; + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final Set pending = ConcurrentHashMap.newKeySet(); + private final Thread worker; + private volatile boolean running = true; + + private DayStats stats; + private boolean budgetWarned; + + private static final Gson GSON = new GsonBuilder().create(); + + private static final class DayStats { + String date = LocalDate.now().toString(); + int requests; + } + + public RemoteQueue(RemoteTranslator translator, String targetLang, int dailyBudget, + Path configDir, BiConsumer onResult) { + this.translator = translator; + this.targetLang = targetLang; + this.dailyBudget = dailyBudget; + this.statsFile = configDir.resolve("remote-stats.json"); + this.onResult = onResult; + this.stats = loadStats(); + this.worker = new Thread(this::run, "SkyblockTranslator-Remote"); + this.worker.setDaemon(true); + this.worker.start(); + } + + /** Queues a template unless it is already queued or in flight. */ + public void submit(String templateKey) { + if (running && pending.add(templateKey)) { + queue.offer(templateKey); + } + } + + public void shutdown() { + running = false; + worker.interrupt(); + saveStats(); + } + + private void run() { + while (running) { + String key; + try { + key = queue.take(); + } catch (InterruptedException e) { + return; + } + if (!budgetAvailable()) { + pending.remove(key); + continue; + } + try { + String translated = translator.translate(key, targetLang); + stats.requests++; + if (stats.requests % 25 == 0) { + saveStats(); + } + if (translated != null && !translated.isBlank()) { + onResult.accept(key, translated); + } + pending.remove(key); + Thread.sleep(MIN_INTERVAL_MS); + } catch (InterruptedException e) { + return; + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.warn("Online translation failed ({}), backing off: {}", + translator.name(), e.toString()); + pending.remove(key); + try { + Thread.sleep(ERROR_BACKOFF_MS); + } catch (InterruptedException ie) { + return; + } + } + } + } + + private boolean budgetAvailable() { + String today = LocalDate.now().toString(); + if (!today.equals(stats.date)) { + stats = new DayStats(); + budgetWarned = false; + } + if (stats.requests >= dailyBudget) { + if (!budgetWarned) { + budgetWarned = true; + SkyblockTranslatorClient.LOGGER.info( + "Daily online translation budget ({}) reached; dictionary and cache keep working", dailyBudget); + } + return false; + } + return true; + } + + private DayStats loadStats() { + if (Files.exists(statsFile)) { + try (Reader reader = Files.newBufferedReader(statsFile, StandardCharsets.UTF_8)) { + DayStats loaded = GSON.fromJson(reader, DayStats.class); + if (loaded != null && loaded.date != null) { + return loaded; + } + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.warn("Could not read {}", statsFile, e); + } + } + return new DayStats(); + } + + private void saveStats() { + try { + Files.createDirectories(statsFile.getParent()); + try (Writer writer = Files.newBufferedWriter(statsFile, StandardCharsets.UTF_8)) { + GSON.toJson(stats, writer); + } + } catch (IOException e) { + SkyblockTranslatorClient.LOGGER.warn("Could not save {}", statsFile, e); + } + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteTranslator.java b/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteTranslator.java new file mode 100644 index 0000000..04bdf70 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/remote/RemoteTranslator.java @@ -0,0 +1,17 @@ +package dev.laybalt.skyblocktranslator.remote; + +import java.io.IOException; + +/** + * A machine-translation backend. Implementations are blocking and are only ever + * called from the {@link RemoteQueue} worker thread — never from the render thread. + */ +public interface RemoteTranslator { + /** + * @param text §-stripped template, may contain {0}-style placeholders + * @param targetLang two-letter target code ("ru") + */ + String translate(String text, String targetLang) throws IOException, InterruptedException; + + String name(); +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java b/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java new file mode 100644 index 0000000..eaa5abc --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java @@ -0,0 +1,152 @@ +package dev.laybalt.skyblocktranslator.ui; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import dev.isxander.yacl3.api.ConfigCategory; +import dev.isxander.yacl3.api.Option; +import dev.isxander.yacl3.api.OptionDescription; +import dev.isxander.yacl3.api.OptionGroup; +import dev.isxander.yacl3.api.YetAnotherConfigLib; +import dev.isxander.yacl3.api.controller.BooleanControllerBuilder; +import dev.isxander.yacl3.api.controller.DropdownStringControllerBuilder; +import dev.isxander.yacl3.api.controller.IntegerFieldControllerBuilder; +import dev.isxander.yacl3.api.controller.StringControllerBuilder; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** + * The in-game settings UI (YACL): general toggles, language, online translation + * backend, and a live string editor over the most recently seen templates — + * edits are saved as user overrides that beat the dictionary and the MT cache. + */ +public final class ConfigScreens { + private static final List LANGUAGES = List.of( + "ru_ru", "uk_ua", "de_de", "fr_fr", "es_es", "pt_br", "pl_pl", "tr_tr", "zh_cn", "ja_jp", "ko_kr"); + private static final List PROVIDERS = List.of("google", "libretranslate"); + + private ConfigScreens() { + } + + public static Screen create(Screen parent) { + ModConfig config = ModConfig.get(); + Map editedOverrides = new LinkedHashMap<>(); + + var general = ConfigCategory.createBuilder() + .name(Component.translatable("sbt.config.category.general")) + .option(bool("sbt.config.enabled", true, () -> config.enabled, v -> config.enabled = v)) + .option(Option.createBuilder() + .name(Component.translatable("sbt.config.language")) + .description(OptionDescription.of(Component.translatable("sbt.config.language.desc"))) + .binding("ru_ru", () -> config.language, v -> config.language = v) + .controller(opt -> DropdownStringControllerBuilder.create(opt).values(LANGUAGES)) + .build()) + .option(bool("sbt.config.onlyOnHypixel", true, () -> config.onlyOnHypixel, v -> config.onlyOnHypixel = v)) + .group(OptionGroup.createBuilder() + .name(Component.translatable("sbt.config.group.categories")) + .option(bool("sbt.config.translateItems", true, () -> config.translateItems, v -> config.translateItems = v)) + .option(bool("sbt.config.translateMenus", true, () -> config.translateMenus, v -> config.translateMenus = v)) + .option(bool("sbt.config.translateDialogs", true, () -> config.translateDialogs, v -> config.translateDialogs = v)) + .option(bool("sbt.config.translatePlayerChat", false, () -> config.translatePlayerChat, v -> config.translatePlayerChat = v)) + .build()) + .build(); + + var online = ConfigCategory.createBuilder() + .name(Component.translatable("sbt.config.category.online")) + .option(bool("sbt.config.translateOnline", true, () -> config.translateOnline, v -> config.translateOnline = v)) + .option(Option.createBuilder() + .name(Component.translatable("sbt.config.onlineProvider")) + .description(OptionDescription.of(Component.translatable("sbt.config.onlineProvider.desc"))) + .binding("google", () -> config.onlineProvider, v -> config.onlineProvider = v) + .controller(opt -> DropdownStringControllerBuilder.create(opt).values(PROVIDERS)) + .build()) + .option(Option.createBuilder() + .name(Component.translatable("sbt.config.dailyOnlineBudget")) + .description(OptionDescription.of(Component.translatable("sbt.config.dailyOnlineBudget.desc"))) + .binding(2000, () -> config.dailyOnlineBudget, v -> config.dailyOnlineBudget = v) + .controller(opt -> IntegerFieldControllerBuilder.create(opt).range(0, 100_000)) + .build()) + .option(Option.createBuilder() + .name(Component.translatable("sbt.config.libreUrl")) + .description(OptionDescription.of(Component.translatable("sbt.config.libreUrl.desc"))) + .binding("", () -> config.libreTranslateUrl, v -> config.libreTranslateUrl = v) + .controller(StringControllerBuilder::create) + .build()) + .option(Option.createBuilder() + .name(Component.translatable("sbt.config.libreApiKey")) + .description(OptionDescription.of(Component.translatable("sbt.config.libreApiKey.desc"))) + .binding("", () -> config.libreTranslateApiKey, v -> config.libreTranslateApiKey = v) + .controller(StringControllerBuilder::create) + .build()) + .build(); + + var editorBuilder = ConfigCategory.createBuilder() + .name(Component.translatable("sbt.config.category.editor")); + List> recent = TranslationEngine.get().recentSeenSnapshot(); + int shown = 0; + for (Map.Entry entry : recent) { + if (shown++ >= 50) { + break; + } + String template = entry.getKey(); + String current = entry.getValue(); + editorBuilder.option(Option.createBuilder() + .name(Component.literal(shorten(template))) + .description(OptionDescription.of( + Component.translatable("sbt.config.editor.entry.desc", template))) + .binding(current, () -> editedOverrides.getOrDefault(template, current), + v -> editedOverrides.put(template, v)) + .controller(StringControllerBuilder::create) + .build()); + } + if (shown == 0) { + editorBuilder.option(Option.createBuilder() + .name(Component.translatable("sbt.config.editor.empty")) + .description(OptionDescription.of(Component.translatable("sbt.config.editor.empty.desc"))) + .binding(false, () -> false, v -> { + }) + .controller(BooleanControllerBuilder::create) + .build()); + } + + return YetAnotherConfigLib.createBuilder() + .title(Component.translatable("sbt.config.title")) + .category(general) + .category(online) + .category(editorBuilder.build()) + .save(() -> { + config.save(); + applyOverrides(editedOverrides); + TranslationEngine.reload(); + }) + .build() + .generateScreen(parent); + } + + private static void applyOverrides(Map edited) { + if (edited.isEmpty()) { + return; + } + var overrides = TranslationEngine.get().overrides(); + edited.forEach(overrides::put); + overrides.save(); + } + + private static Option bool(String key, boolean defaultValue, + java.util.function.Supplier getter, java.util.function.Consumer setter) { + return Option.createBuilder() + .name(Component.translatable(key)) + .description(OptionDescription.of(Component.translatable(key + ".desc"))) + .binding(defaultValue, getter::get, setter::accept) + .controller(opt -> BooleanControllerBuilder.create(opt).yesNoFormatter()) + .build(); + } + + private static String shorten(String template) { + return template.length() <= 40 ? template : template.substring(0, 37) + "..."; + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/ui/ModMenuIntegration.java b/src/main/java/dev/laybalt/skyblocktranslator/ui/ModMenuIntegration.java new file mode 100644 index 0000000..4e1443c --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/ui/ModMenuIntegration.java @@ -0,0 +1,12 @@ +package dev.laybalt.skyblocktranslator.ui; + +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; + +/** Puts the settings screen behind Mod Menu's "Configure" button. */ +public class ModMenuIntegration implements ModMenuApi { + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + return ConfigScreens::create; + } +} diff --git a/src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json b/src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json index 3a51669..e3c244b 100644 --- a/src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json +++ b/src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json @@ -1,22 +1,22 @@ { - "Damage: +{0}": "Урон: +{0}", - "Strength: +{0}": "Сила: +{0}", - "Crit Chance: +{0}": "Крит. шанс: +{0}", - "Crit Damage: +{0}": "Крит. урон: +{0}", - "Attack Speed: +{0}": "Скорость атаки: +{0}", - "Health: +{0}": "Здоровье: +{0}", - "Defense: +{0}": "Защита: +{0}", - "Speed: +{0}": "Скорость: +{0}", - "Intelligence: +{0}": "Интеллект: +{0}", - "Magic Find: +{0}": "Магическая удача: +{0}", - "Pet Luck: +{0}": "Удача питомцев: +{0}", - "Ferocity: +{0}": "Свирепость: +{0}", - "Mining Speed: +{0}": "Скорость добычи: +{0}", - "Mining Fortune: +{0}": "Удача добычи: +{0}", - "Farming Fortune: +{0}": "Удача фермерства: +{0}", - "Foraging Fortune: +{0}": "Удача собирательства: +{0}", - "Sea Creature Chance: +{0}": "Шанс морского существа: +{0}", - "Fishing Speed: +{0}": "Скорость рыбалки: +{0}", + "Damage: {0}": "Урон: {0}", + "Strength: {0}": "Сила: {0}", + "Crit Chance: {0}": "Крит. шанс: {0}", + "Crit Damage: {0}": "Крит. урон: {0}", + "Attack Speed: {0}": "Скорость атаки: {0}", + "Health: {0}": "Здоровье: {0}", + "Defense: {0}": "Защита: {0}", + "Speed: {0}": "Скорость: {0}", + "Intelligence: {0}": "Интеллект: {0}", + "Magic Find: {0}": "Магическая удача: {0}", + "Pet Luck: {0}": "Удача питомцев: {0}", + "Ferocity: {0}": "Свирепость: {0}", + "Mining Speed: {0}": "Скорость добычи: {0}", + "Mining Fortune: {0}": "Удача добычи: {0}", + "Farming Fortune: {0}": "Удача фермерства: {0}", + "Foraging Fortune: {0}": "Удача собирательства: {0}", + "Sea Creature Chance: {0}": "Шанс морского существа: {0}", + "Fishing Speed: {0}": "Скорость рыбалки: {0}", "Gear Score: {0}": "Рейтинг снаряжения: {0}", "COMMON": "ОБЫЧНЫЙ", "UNCOMMON": "НЕОБЫЧНЫЙ", diff --git a/src/main/resources/assets/skyblock-translator/lang/en_us.json b/src/main/resources/assets/skyblock-translator/lang/en_us.json new file mode 100644 index 0000000..768e4ad --- /dev/null +++ b/src/main/resources/assets/skyblock-translator/lang/en_us.json @@ -0,0 +1,40 @@ +{ + "key.skyblock-translator.config": "Open Translator Settings", + "key.categories.skyblock-translator.main": "SkyBlock Translator", + + "sbt.config.title": "SkyBlock Translator", + "sbt.config.category.general": "General", + "sbt.config.category.online": "Online Translation", + "sbt.config.category.editor": "String Editor", + + "sbt.config.enabled": "Enable translation", + "sbt.config.enabled.desc": "Master switch for the whole mod.", + "sbt.config.language": "Language", + "sbt.config.language.desc": "Target language. Bundled dictionaries exist for Russian; other languages rely on online translation.", + "sbt.config.onlyOnHypixel": "Only on Hypixel", + "sbt.config.onlyOnHypixel.desc": "Translate only while connected to Hypixel. Disable to test in singleplayer.", + "sbt.config.group.categories": "What to translate", + "sbt.config.translateItems": "Item tooltips", + "sbt.config.translateItems.desc": "Item names and lore in container screens.", + "sbt.config.translateMenus": "Menu titles", + "sbt.config.translateMenus.desc": "Container titles and labels.", + "sbt.config.translateDialogs": "NPC dialogues & system messages", + "sbt.config.translateDialogs.desc": "Server messages in chat: dialogues, quests, announcements.", + "sbt.config.translatePlayerChat": "Player chat", + "sbt.config.translatePlayerChat.desc": "Translate messages written by other players.", + + "sbt.config.translateOnline": "Online translation", + "sbt.config.translateOnline.desc": "Translate unknown strings through an online service and cache the result locally. With this off, only dictionaries and the cache are used.", + "sbt.config.onlineProvider": "Provider", + "sbt.config.onlineProvider.desc": "google — free, works out of the box. libretranslate — your own instance (URL below).", + "sbt.config.dailyOnlineBudget": "Daily request limit", + "sbt.config.dailyOnlineBudget.desc": "Maximum online translation requests per day. Cached translations don't count.", + "sbt.config.libreUrl": "LibreTranslate URL", + "sbt.config.libreUrl.desc": "Instance address, e.g. https://libretranslate.example.com", + "sbt.config.libreApiKey": "LibreTranslate API key", + "sbt.config.libreApiKey.desc": "Leave empty if the instance doesn't require one.", + + "sbt.config.editor.entry.desc": "Original: %s\n\nEdit the translation and save. Your edit becomes an override — it beats the dictionary and the online cache.", + "sbt.config.editor.empty": "Nothing seen yet", + "sbt.config.editor.empty.desc": "Open some menus or tooltips in game, then come back — the latest strings will show up here for editing." +} diff --git a/src/main/resources/assets/skyblock-translator/lang/ru_ru.json b/src/main/resources/assets/skyblock-translator/lang/ru_ru.json new file mode 100644 index 0000000..1de5384 --- /dev/null +++ b/src/main/resources/assets/skyblock-translator/lang/ru_ru.json @@ -0,0 +1,40 @@ +{ + "key.skyblock-translator.config": "Открыть настройки переводчика", + "key.categories.skyblock-translator.main": "SkyBlock Translator", + + "sbt.config.title": "SkyBlock Translator", + "sbt.config.category.general": "Общие", + "sbt.config.category.online": "Онлайн-перевод", + "sbt.config.category.editor": "Правка строк", + + "sbt.config.enabled": "Включить перевод", + "sbt.config.enabled.desc": "Главный выключатель всего мода.", + "sbt.config.language": "Язык", + "sbt.config.language.desc": "Целевой язык. Встроенные словари есть для русского; остальные языки работают через онлайн-перевод.", + "sbt.config.onlyOnHypixel": "Только на Hypixel", + "sbt.config.onlyOnHypixel.desc": "Переводить только на Hypixel. Отключите для теста в одиночной игре.", + "sbt.config.group.categories": "Что переводить", + "sbt.config.translateItems": "Тултипы предметов", + "sbt.config.translateItems.desc": "Названия и описания предметов в меню.", + "sbt.config.translateMenus": "Заголовки меню", + "sbt.config.translateMenus.desc": "Названия сундуков-меню и подписи.", + "sbt.config.translateDialogs": "Диалоги NPC и системные сообщения", + "sbt.config.translateDialogs.desc": "Серверные сообщения в чате: диалоги, задания, объявления.", + "sbt.config.translatePlayerChat": "Чат игроков", + "sbt.config.translatePlayerChat.desc": "Переводить сообщения других игроков.", + + "sbt.config.translateOnline": "Онлайн-перевод", + "sbt.config.translateOnline.desc": "Неизвестные строки переводятся онлайн-сервисом и кэшируются локально. Если выключено — работают только словари и кэш.", + "sbt.config.onlineProvider": "Провайдер", + "sbt.config.onlineProvider.desc": "google — бесплатно, работает из коробки. libretranslate — свой инстанс (URL ниже).", + "sbt.config.dailyOnlineBudget": "Дневной лимит запросов", + "sbt.config.dailyOnlineBudget.desc": "Максимум онлайн-переводов в день. Уже закэшированное не тратит лимит.", + "sbt.config.libreUrl": "URL LibreTranslate", + "sbt.config.libreUrl.desc": "Адрес инстанса, например https://libretranslate.example.com", + "sbt.config.libreApiKey": "API-ключ LibreTranslate", + "sbt.config.libreApiKey.desc": "Оставьте пустым, если инстанс не требует ключа.", + + "sbt.config.editor.entry.desc": "Оригинал: %s\n\nОтредактируйте перевод и сохраните. Правка станет переопределением — сильнее словаря и онлайн-кэша.", + "sbt.config.editor.empty": "Пока ничего не видели", + "sbt.config.editor.empty.desc": "Откройте в игре пару меню или тултипов и вернитесь — здесь появятся последние строки для правки." +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 5b6f587..d540f97 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -18,6 +18,9 @@ "entrypoints": { "client": [ "dev.laybalt.skyblocktranslator.SkyblockTranslatorClient" + ], + "modmenu": [ + "dev.laybalt.skyblocktranslator.ui.ModMenuIntegration" ] }, "mixins": [ @@ -27,6 +30,10 @@ "fabricloader": ">=0.19.3", "minecraft": "~26.2", "java": ">=25", - "fabric-api": "*" + "fabric-api": "*", + "yet_another_config_lib_v3": "*" + }, + "suggests": { + "modmenu": "*" } } diff --git a/src/main/resources/skyblock-translator.mixins.json b/src/main/resources/skyblock-translator.mixins.json index 5863d62..692d242 100644 --- a/src/main/resources/skyblock-translator.mixins.json +++ b/src/main/resources/skyblock-translator.mixins.json @@ -3,7 +3,8 @@ "package": "dev.laybalt.skyblocktranslator.mixin", "compatibilityLevel": "JAVA_25", "client": [ - "AbstractContainerScreenMixin" + "AbstractContainerScreenMixin", + "ChatComponentMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java index 2d78128..c53dc7a 100644 --- a/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java +++ b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java @@ -16,36 +16,65 @@ void plainStringWithoutNumbersIsUntouched() { } @Test - void extractsSimpleNumber() { + void extractsSignedNumber() { Normalizer.Template t = Normalizer.normalize("Damage: +100"); - assertEquals("Damage: +{0}", t.key()); - assertEquals(List.of("100"), t.args()); + assertEquals("Damage: {0}", t.key()); + assertEquals(1, t.args().size()); + assertEquals("+100", t.args().get(0).raw()); } @Test void extractsNumbersWithSeparatorsDecimalsAndPercent() { Normalizer.Template t = Normalizer.normalize("Crit Chance: +12.5% Coins: 1,234,567"); - assertEquals("Crit Chance: +{0} Coins: {1}", t.key()); - assertEquals(List.of("12.5%", "1,234,567"), t.args()); + assertEquals("Crit Chance: {0} Coins: {1}", t.key()); + assertEquals("+12.5%", t.args().get(0).raw()); + assertEquals("1,234,567", t.args().get(1).raw()); } @Test - void restoreSubstitutesPlaceholdersBack() { - Normalizer.Template t = Normalizer.normalize("Damage: +1,024"); - String restored = Normalizer.restore("Урон: +{0}", t.args()); - assertEquals("Урон: +1,024", restored); + void argumentsKeepTheirColorCodes() { + Normalizer.Template t = Normalizer.normalize("§7Damage: §c+1,024"); + assertEquals("Damage: {0}", t.key()); + assertEquals("§c+1,024", t.args().get(0).raw()); + assertEquals("§7", t.args().get(0).after()); + assertEquals("Урон: §c+1,024§7", Normalizer.restore("Урон: {0}", t.args())); + } + + @Test + void surroundingColorIsRestoredAfterColoredArgument() { + Normalizer.Template t = Normalizer.normalize("§7Gain §b+250§7 Wisdom"); + assertEquals("Gain {0} Wisdom", t.key()); + assertEquals("§b+250", t.args().get(0).raw()); + assertEquals("§7", t.args().get(0).after()); + } + + @Test + void uncoloredArgumentAddsNothingAfter() { + Normalizer.Template t = Normalizer.normalize("Requires Combat Skill 25!"); + assertEquals("Requires Combat Skill {0}!", t.key()); + assertEquals("25", t.args().get(0).raw()); + assertEquals("", t.args().get(0).after()); + assertEquals("Requires Combat Skill 25!", Normalizer.restore(t.key(), t.args())); + } + + @Test + void trailingPunctuationStaysOutsideTheNumber() { + Normalizer.Template t = Normalizer.normalize("Reduced to 10."); + assertEquals("Reduced to {0}.", t.key()); + assertEquals("10", t.args().get(0).raw()); } @Test void restoreKeepsUnknownBracesLiteral() { - assertEquals("Урон: +{9} x", Normalizer.restore("Урон: +{9} x", List.of("100"))); - assertEquals("{нет} 100", Normalizer.restore("{нет} {0}", List.of("100"))); + List args = List.of(new Normalizer.Arg("100", "")); + assertEquals("Урон: {9} x", Normalizer.restore("Урон: {9} x", args)); + assertEquals("{нет} 100", Normalizer.restore("{нет} {0}", args)); } @Test - void roundTripNormalizeRestoreIsIdentity() { - String original = "Requires Combat Skill 25!"; - Normalizer.Template t = Normalizer.normalize(original); - assertEquals(original, Normalizer.restore(t.key(), t.args())); + void placeholderCountCountsDistinctPlaceholders() { + assertEquals(0, Normalizer.placeholderCount("no placeholders")); + assertEquals(2, Normalizer.placeholderCount("a {0} b {1}")); + assertEquals(1, Normalizer.placeholderCount("Урон: {0}")); } } From 1b7b4645fa9f61a9cca987df7f2f2ae2f1fd972f Mon Sep 17 00:00:00 2001 From: Somikyy Date: Thu, 23 Jul 2026 12:44:50 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Fix=20=C2=A7k=20smearing,=20add=20scoreboar?= =?UTF-8?q?d/tab/bossbar=20translation,=20protect=20names=20from=20MT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lines containing §k are now translated per segment: obfuscated runes and the full §-code structure stay exactly where they were, only real text segments are translated (fixes the garbled rarity line) - New surfaces: scoreboard sidebar (Hud.displayScoreboardSidebar), boss bar (BossHealthOverlay), tab list (entries + header/footer), each behind its own config toggle - MT hygiene: tab entries never reach the online translator (player names), and MT is only attempted for multi-word phrases anywhere else Co-Authored-By: Claude Opus 4.8 --- .../skyblocktranslator/config/ModConfig.java | 4 + .../mixin/BossHealthOverlayMixin.java | 30 ++++++++ .../skyblocktranslator/mixin/HudMixin.java | 36 +++++++++ .../mixin/PlayerTabOverlayMixin.java | 53 +++++++++++++ .../pipeline/LegacyText.java | 36 +++++++++ .../pipeline/TranslationEngine.java | 76 ++++++++++++++++--- .../skyblocktranslator/ui/ConfigScreens.java | 3 + .../skyblock-translator/lang/en_us.json | 6 ++ .../skyblock-translator/lang/ru_ru.json | 6 ++ .../resources/skyblock-translator.mixins.json | 5 +- .../pipeline/SegmentTest.java | 56 ++++++++++++++ 11 files changed, 299 insertions(+), 12 deletions(-) create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/mixin/BossHealthOverlayMixin.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/mixin/HudMixin.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/mixin/PlayerTabOverlayMixin.java create mode 100644 src/test/java/dev/laybalt/skyblocktranslator/pipeline/SegmentTest.java diff --git a/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java b/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java index 2a43cd2..fdde91f 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java @@ -32,6 +32,10 @@ public final class ModConfig { public boolean translateDialogs = true; /** Messages written by other players; off by default. */ public boolean translatePlayerChat = false; + public boolean translateScoreboard = true; + /** Tab entries never use online MT (player names must not be machine-translated). */ + public boolean translateTabList = true; + public boolean translateBossBar = true; /** Send unknown strings to an online translator and cache the results. */ public boolean translateOnline = true; /** "google" (free, zero setup) or "libretranslate" (own instance below). */ diff --git a/src/main/java/dev/laybalt/skyblocktranslator/mixin/BossHealthOverlayMixin.java b/src/main/java/dev/laybalt/skyblocktranslator/mixin/BossHealthOverlayMixin.java new file mode 100644 index 0000000..7dfd59d --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/mixin/BossHealthOverlayMixin.java @@ -0,0 +1,30 @@ +package dev.laybalt.skyblocktranslator.mixin; + +import net.minecraft.client.gui.components.BossHealthOverlay; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.detect.HypixelDetector; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** Boss bar titles (SkyBlock uses them for event/objective banners). */ +@Mixin(BossHealthOverlay.class) +public abstract class BossHealthOverlayMixin { + @ModifyArg( + method = "extractRenderState", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;III)V" + ), + index = 1 + ) + private Component skyblockTranslator$translateBossBar(Component original) { + if (!ModConfig.get().translateBossBar || !HypixelDetector.isTranslationActive()) { + return original; + } + return TranslationEngine.get().translate(original); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/mixin/HudMixin.java b/src/main/java/dev/laybalt/skyblocktranslator/mixin/HudMixin.java new file mode 100644 index 0000000..d82d65c --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/mixin/HudMixin.java @@ -0,0 +1,36 @@ +package dev.laybalt.skyblocktranslator.mixin; + +import net.minecraft.client.gui.Hud; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.detect.HypixelDetector; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** + * Scoreboard sidebar translation. Every text draw inside + * {@code displayScoreboardSidebar} (title, line names, score values) goes + * through {@code GuiGraphicsExtractor.text(Font, Component, IIIZ)}; swapping the + * component translates exactly what is on screen. Score values are numbers and + * pass through the engine untouched. + */ +@Mixin(Hud.class) +public abstract class HudMixin { + @ModifyArg( + method = "displayScoreboardSidebar", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;IIIZ)V" + ), + index = 1 + ) + private Component skyblockTranslator$translateSidebar(Component original) { + if (!ModConfig.get().translateScoreboard || !HypixelDetector.isTranslationActive()) { + return original; + } + return TranslationEngine.get().translate(original); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/mixin/PlayerTabOverlayMixin.java b/src/main/java/dev/laybalt/skyblocktranslator/mixin/PlayerTabOverlayMixin.java new file mode 100644 index 0000000..a4ff372 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/mixin/PlayerTabOverlayMixin.java @@ -0,0 +1,53 @@ +package dev.laybalt.skyblocktranslator.mixin; + +import net.minecraft.client.gui.components.PlayerTabOverlay; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyVariable; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.detect.HypixelDetector; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** + * Tab list translation. SkyBlock fills the tab with fake entries whose display + * names are info lines, but real player names appear there too — so entries are + * translated with MT disabled (dictionary/cache/overrides only, names never get + * sent to an online translator). Header and footer are pure info text and may + * use the full pipeline. + */ +@Mixin(PlayerTabOverlay.class) +public abstract class PlayerTabOverlayMixin { + @Inject(method = "getNameForDisplay", at = @At("RETURN"), cancellable = true) + private void skyblockTranslator$translateEntry(PlayerInfo info, CallbackInfoReturnable cir) { + if (!ModConfig.get().translateTabList || !HypixelDetector.isTranslationActive()) { + return; + } + Component original = cir.getReturnValue(); + Component translated = TranslationEngine.get().translate(original, false); + if (translated != original) { + cir.setReturnValue(translated); + } + } + + @ModifyVariable(method = "setHeader", at = @At("HEAD"), argsOnly = true) + private Component skyblockTranslator$translateHeader(Component header) { + return translateInfo(header); + } + + @ModifyVariable(method = "setFooter", at = @At("HEAD"), argsOnly = true) + private Component skyblockTranslator$translateFooter(Component footer) { + return translateInfo(footer); + } + + private static Component translateInfo(Component component) { + if (component == null || !ModConfig.get().translateTabList || !HypixelDetector.isTranslationActive()) { + return component; + } + return TranslationEngine.get().translate(component); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java index 01eefde..495e7db 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java @@ -86,4 +86,40 @@ public static String leadingCodes(String legacy) { } return legacy.substring(0, i); } + + /** + * A code-delimited run of a legacy string. {@code codes} is the (possibly empty) + * §-run that precedes {@code text}; {@code obfuscated} means §k is in effect, + * i.e. the text is decorative garbage that must never be translated. + */ + public record Segment(String codes, String text, boolean obfuscated) { + } + + /** Splits a legacy string into styled segments, tracking §k state across resets. */ + public static java.util.List segments(String legacy) { + java.util.List out = new java.util.ArrayList<>(); + boolean obfuscated = false; + int i = 0; + while (i < legacy.length()) { + StringBuilder codes = new StringBuilder(); + while (i + 1 < legacy.length() && legacy.charAt(i) == SECTION) { + char code = Character.toLowerCase(legacy.charAt(i + 1)); + if (code == 'k') { + obfuscated = true; + } else if (code == 'r' || (code >= '0' && code <= '9') || (code >= 'a' && code <= 'f')) { + obfuscated = false; // colors and reset clear formatting + } + codes.append(legacy, i, i + 2); + i += 2; + } + int start = i; + while (i < legacy.length() && legacy.charAt(i) != SECTION) { + i++; + } + if (codes.length() > 0 || i > start) { + out.add(new Segment(codes.toString(), legacy.substring(start, i), obfuscated)); + } + } + return out; + } } diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java index a078944..6fd4176 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java @@ -159,6 +159,15 @@ public void flushMemo() { /** Translates one component; returns the original instance when there is no translation. */ public Component translate(Component original) { + return translate(original, true); + } + + /** + * @param allowMt false for text that must never reach an online translator + * (e.g. tab-list entries containing player names); dictionary, + * cache and overrides still apply + */ + public Component translate(Component original, boolean allowMt) { String legacy = LegacyText.toLegacy(original); if (legacy.isBlank()) { return original; @@ -169,7 +178,7 @@ public Component translate(Component original) { return known; } } - Component result = compute(legacy, original); + Component result = compute(legacy, original, allowMt); synchronized (memo) { memo.put(legacy, result); } @@ -192,26 +201,71 @@ public List translateLines(List lines) { return out != null ? out : lines; } - private Component compute(String legacy, Component original) { + private Component compute(String legacy, Component original, boolean allowMt) { String plain = LegacyText.stripCodes(legacy); if (plain.isBlank()) { return original; } + // Lines with §k decorations are handled per segment: whole-line templates + // would swallow the obfuscated garbage into the key and the §k would then + // smear over the entire translated line. + if (legacy.contains("§k") || legacy.contains("§K")) { + return translateSegmented(legacy, original, allowMt); + } Normalizer.Template template = Normalizer.normalize(legacy); + String translation = lookup(template.key(), allowMt); + if (translation == null) { + return original; + } + String restored = Normalizer.restore(translation, template.args()); + return Component.literal(LegacyText.leadingCodes(legacy) + restored); + } + + /** + * Translates each non-obfuscated text segment independently, keeping the + * original §-code structure (and the §k runes) exactly where they were. + */ + private Component translateSegmented(String legacy, Component original, boolean allowMt) { + StringBuilder out = new StringBuilder(legacy.length()); + boolean changed = false; + for (LegacyText.Segment segment : LegacyText.segments(legacy)) { + out.append(segment.codes()); + String text = segment.text(); + if (segment.obfuscated() || text.isBlank() || !hasLetters(text)) { + out.append(text); + continue; + } + Normalizer.Template template = Normalizer.normalize(text); + String translation = lookup(template.key(), allowMt); + if (translation != null) { + out.append(Normalizer.restore(translation, template.args())); + changed = true; + } else { + out.append(text); + } + } + return changed ? Component.literal(out.toString()) : original; + } + + /** Provider chain + MT scheduling for one template key; null when untranslated. */ + @Nullable + private String lookup(String key, boolean allowMt) { for (TranslationProvider provider : providers) { - String translation = provider.lookup(template.key()); + String translation = provider.lookup(key); if (translation != null) { - remember(template.key(), translation); - String restored = Normalizer.restore(translation, template.args()); - return Component.literal(LegacyText.leadingCodes(legacy) + restored); + remember(key, translation); + return translation; } } - remember(template.key(), null); - recordMissing(template.key()); - if (remoteQueue != null && !rejected.contains(template.key()) && hasLetters(template.key())) { - remoteQueue.submit(template.key()); + remember(key, null); + recordMissing(key); + // Only phrases go to MT: single tokens are usually names or item ids, and + // machine-translating player names would be both wrong and rude. + if (allowMt && remoteQueue != null && !rejected.contains(key) + && hasLetters(key) && key.indexOf(' ') >= 0) { + remoteQueue.submit(key); } - return original; + return null; } private int remoteResults; diff --git a/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java b/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java index eaa5abc..cda4e2e 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/ui/ConfigScreens.java @@ -52,6 +52,9 @@ public static Screen create(Screen parent) { .option(bool("sbt.config.translateMenus", true, () -> config.translateMenus, v -> config.translateMenus = v)) .option(bool("sbt.config.translateDialogs", true, () -> config.translateDialogs, v -> config.translateDialogs = v)) .option(bool("sbt.config.translatePlayerChat", false, () -> config.translatePlayerChat, v -> config.translatePlayerChat = v)) + .option(bool("sbt.config.translateScoreboard", true, () -> config.translateScoreboard, v -> config.translateScoreboard = v)) + .option(bool("sbt.config.translateTabList", true, () -> config.translateTabList, v -> config.translateTabList = v)) + .option(bool("sbt.config.translateBossBar", true, () -> config.translateBossBar, v -> config.translateBossBar = v)) .build()) .build(); diff --git a/src/main/resources/assets/skyblock-translator/lang/en_us.json b/src/main/resources/assets/skyblock-translator/lang/en_us.json index 768e4ad..0ad27bd 100644 --- a/src/main/resources/assets/skyblock-translator/lang/en_us.json +++ b/src/main/resources/assets/skyblock-translator/lang/en_us.json @@ -22,6 +22,12 @@ "sbt.config.translateDialogs.desc": "Server messages in chat: dialogues, quests, announcements.", "sbt.config.translatePlayerChat": "Player chat", "sbt.config.translatePlayerChat.desc": "Translate messages written by other players.", + "sbt.config.translateScoreboard": "Scoreboard sidebar", + "sbt.config.translateScoreboard.desc": "The SKYBLOCK sidebar on the right.", + "sbt.config.translateTabList": "Tab list", + "sbt.config.translateTabList.desc": "Tab entries, header and footer. Player names are never sent to an online translator.", + "sbt.config.translateBossBar": "Boss bar", + "sbt.config.translateBossBar.desc": "Event and objective banners at the top of the screen.", "sbt.config.translateOnline": "Online translation", "sbt.config.translateOnline.desc": "Translate unknown strings through an online service and cache the result locally. With this off, only dictionaries and the cache are used.", diff --git a/src/main/resources/assets/skyblock-translator/lang/ru_ru.json b/src/main/resources/assets/skyblock-translator/lang/ru_ru.json index 1de5384..4302fdb 100644 --- a/src/main/resources/assets/skyblock-translator/lang/ru_ru.json +++ b/src/main/resources/assets/skyblock-translator/lang/ru_ru.json @@ -22,6 +22,12 @@ "sbt.config.translateDialogs.desc": "Серверные сообщения в чате: диалоги, задания, объявления.", "sbt.config.translatePlayerChat": "Чат игроков", "sbt.config.translatePlayerChat.desc": "Переводить сообщения других игроков.", + "sbt.config.translateScoreboard": "Сайдбар (скорборд)", + "sbt.config.translateScoreboard.desc": "Панель SKYBLOCK справа.", + "sbt.config.translateTabList": "TAB-список", + "sbt.config.translateTabList.desc": "Записи, шапка и подвал таба. Ники игроков никогда не отправляются в онлайн-переводчик.", + "sbt.config.translateBossBar": "Босс-бар", + "sbt.config.translateBossBar.desc": "Баннеры событий и целей вверху экрана.", "sbt.config.translateOnline": "Онлайн-перевод", "sbt.config.translateOnline.desc": "Неизвестные строки переводятся онлайн-сервисом и кэшируются локально. Если выключено — работают только словари и кэш.", diff --git a/src/main/resources/skyblock-translator.mixins.json b/src/main/resources/skyblock-translator.mixins.json index 692d242..4152d46 100644 --- a/src/main/resources/skyblock-translator.mixins.json +++ b/src/main/resources/skyblock-translator.mixins.json @@ -4,7 +4,10 @@ "compatibilityLevel": "JAVA_25", "client": [ "AbstractContainerScreenMixin", - "ChatComponentMixin" + "BossHealthOverlayMixin", + "ChatComponentMixin", + "HudMixin", + "PlayerTabOverlayMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/test/java/dev/laybalt/skyblocktranslator/pipeline/SegmentTest.java b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/SegmentTest.java new file mode 100644 index 0000000..32476c0 --- /dev/null +++ b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/SegmentTest.java @@ -0,0 +1,56 @@ +package dev.laybalt.skyblocktranslator.pipeline; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Segment splitting for the §k-aware per-segment translation path. */ +class SegmentTest { + @Test + void plainStringIsOneSegment() { + List segments = LegacyText.segments("Hello world"); + assertEquals(1, segments.size()); + assertEquals("", segments.get(0).codes()); + assertEquals("Hello world", segments.get(0).text()); + assertFalse(segments.get(0).obfuscated()); + } + + @Test + void obfuscationIsTrackedAndClearedByColorOrReset() { + // §d§l§ka§r §d§lLEGENDARY§r §d§l§ka — SkyBlock rarity line shape + List segments = LegacyText.segments("§d§l§ka§r §d§lLEGENDARY§r §d§l§ka"); + assertEquals(5, segments.size()); + + assertEquals("§d§l§k", segments.get(0).codes()); + assertEquals("a", segments.get(0).text()); + assertTrue(segments.get(0).obfuscated()); + + assertEquals("§r ", segments.get(1).codes() + segments.get(1).text()); + assertFalse(segments.get(1).obfuscated()); + + assertEquals("§d§l", segments.get(2).codes()); + assertEquals("LEGENDARY", segments.get(2).text()); + assertFalse(segments.get(2).obfuscated()); + + assertEquals("§r ", segments.get(3).codes() + segments.get(3).text()); + assertFalse(segments.get(3).obfuscated()); + + assertEquals("§d§l§k", segments.get(4).codes()); + assertEquals("a", segments.get(4).text()); + assertTrue(segments.get(4).obfuscated()); + } + + @Test + void reassemblingSegmentsReproducesTheOriginal() { + String original = "§7Health: §a+130§8 (+40) §k!!§r done"; + StringBuilder rebuilt = new StringBuilder(); + for (LegacyText.Segment s : LegacyText.segments(original)) { + rebuilt.append(s.codes()).append(s.text()); + } + assertEquals(original, rebuilt.toString()); + } +} From 02001cb7aa713a6fa8f00026fbb85e62c5c2a127 Mon Sep 17 00:00:00 2001 From: Somikyy Date: Thu, 23 Jul 2026 12:53:02 +0200 Subject: [PATCH 3/3] Bump version to 0.2.0-alpha for the first public release Co-Authored-By: Claude Opus 4.8 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1058c42..4e8392a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loader_version=0.19.3 loom_version=1.17-SNAPSHOT # Mod Properties -mod_version=0.1.0 +mod_version=0.2.0-alpha maven_group=dev.laybalt # Dependencies