From 6f403c244c446e1c412bd80cbea87f2ed2170ac1 Mon Sep 17 00:00:00 2001 From: Somikyy Date: Thu, 23 Jul 2026 11:53:28 +0200 Subject: [PATCH] Phase 1 MVP: render-level translation pipeline for container screens Pipeline: Component -> legacy string (LegacyText) -> placeholder template (Normalizer) -> provider chain (bundled dictionary -> local JSON cache), memoized per line because tooltips rebuild every frame. Unknown templates are dumped to untranslated-.txt to grow the dictionaries from real gameplay. - Mixin on AbstractContainerScreen: getTooltipFromContainerItem (item tooltips) and extractLabels/GuiGraphicsExtractor.text (menu titles) - display-only, ItemStacks stay English for other mods - JSON config in config/skyblock-translator/ (language, category toggles, onlyOnHypixel gate with singleplayer override, premium key reserved) - Hypixel detection by server address; SkyBlock scoreboard check is Phase 2 - Starter ru_ru dictionaries (menus, stats) loaded via _index.json - JUnit tests for Normalizer and legacy-string helpers Co-Authored-By: Claude Opus 4.8 --- build.gradle | 3 + .../SkyblockTranslatorClient.java | 9 +- .../skyblocktranslator/config/ModConfig.java | 80 +++++++++ .../detect/HypixelDetector.java | 36 +++++ .../mixin/AbstractContainerScreenMixin.java | 59 +++++++ .../pipeline/LegacyText.java | 89 ++++++++++ .../pipeline/Normalizer.java | 72 +++++++++ .../pipeline/TranslationEngine.java | 152 ++++++++++++++++++ .../providers/DictionaryProvider.java | 81 ++++++++++ .../providers/LocalCacheProvider.java | 75 +++++++++ .../providers/TranslationProvider.java | 15 ++ .../dict/ru_ru/_index.json | 4 + .../skyblock-translator/dict/ru_ru/menus.json | 29 +++- .../skyblock-translator/dict/ru_ru/stats.json | 32 ++++ .../resources/skyblock-translator.mixins.json | 4 +- .../pipeline/LegacyStringTest.java | 22 +++ .../pipeline/NormalizerTest.java | 51 ++++++ 17 files changed, 807 insertions(+), 6 deletions(-) create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/detect/HypixelDetector.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/mixin/AbstractContainerScreenMixin.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/providers/DictionaryProvider.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/providers/LocalCacheProvider.java create mode 100644 src/main/java/dev/laybalt/skyblocktranslator/providers/TranslationProvider.java create mode 100644 src/main/resources/assets/skyblock-translator/dict/ru_ru/_index.json create mode 100644 src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json create mode 100644 src/test/java/dev/laybalt/skyblocktranslator/pipeline/LegacyStringTest.java create mode 100644 src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java diff --git a/build.gradle b/build.gradle index a2092fa..db88df3 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,9 @@ dependencies { // Dev-environment only: real-account login for runClient (not bundled in the jar) runtimeOnly "me.djtheredstoner:DevAuth-fabric:1.2.2" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } processResources { diff --git a/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java b/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java index 40aff46..32b3428 100644 --- a/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java +++ b/src/main/java/dev/laybalt/skyblocktranslator/SkyblockTranslatorClient.java @@ -1,15 +1,22 @@ package dev.laybalt.skyblocktranslator; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + public class SkyblockTranslatorClient implements ClientModInitializer { public static final String MOD_ID = "skyblock-translator"; public static final Logger LOGGER = LoggerFactory.getLogger("SkyBlock Translator"); @Override public void onInitializeClient() { - LOGGER.info("SkyBlock Translator initialized"); + ModConfig config = ModConfig.get(); + TranslationEngine.init(); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> TranslationEngine.get().cache().save()); + 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 new file mode 100644 index 0000000..964a77d --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/config/ModConfig.java @@ -0,0 +1,80 @@ +package dev.laybalt.skyblocktranslator.config; + +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 com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.fabricmc.loader.api.FabricLoader; + +import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient; + +/** + * Mod configuration, stored as {@code config/skyblock-translator/config.json}. + * A missing file is created with defaults on first launch. + */ +public final class ModConfig { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static ModConfig instance; + + public boolean enabled = true; + /** Dictionary/cache language, matches the dict folder name. */ + public String language = "ru_ru"; + /** Only translate while connected to Hypixel. Turn off to test in singleplayer. */ + public boolean onlyOnHypixel = true; + public boolean translateItems = true; + public boolean translateMenus = true; + /** Reserved for Phase 2 (chat / NPC dialogue translation). */ + public boolean translateChat = false; + /** Append unknown templates to config/skyblock-translator/untranslated-.txt. */ + public boolean dumpUntranslated = true; + /** Reserved for Phase 3 (premium cloud translation). */ + public String premiumKey = ""; + + public static ModConfig get() { + if (instance == null) { + instance = load(); + } + return instance; + } + + public static Path directory() { + return FabricLoader.getInstance().getConfigDir().resolve("skyblock-translator"); + } + + private static Path file() { + return directory().resolve("config.json"); + } + + private static ModConfig load() { + Path file = file(); + if (Files.exists(file)) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + ModConfig loaded = GSON.fromJson(reader, ModConfig.class); + if (loaded != null) { + return loaded; + } + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to load config, using defaults", e); + } + } + ModConfig fresh = new ModConfig(); + fresh.save(); + return fresh; + } + + public void save() { + try { + Files.createDirectories(directory()); + try (Writer writer = Files.newBufferedWriter(file(), StandardCharsets.UTF_8)) { + GSON.toJson(this, writer); + } + } catch (IOException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to save config", e); + } + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/detect/HypixelDetector.java b/src/main/java/dev/laybalt/skyblocktranslator/detect/HypixelDetector.java new file mode 100644 index 0000000..bfbe502 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/detect/HypixelDetector.java @@ -0,0 +1,36 @@ +package dev.laybalt.skyblocktranslator.detect; + +import java.util.Locale; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; + +import dev.laybalt.skyblocktranslator.config.ModConfig; + +/** + * Decides whether translation should be active right now. + * + *

Phase 1 gates on the server address only; SkyBlock-specific detection via + * the scoreboard title arrives in Phase 2. {@code onlyOnHypixel=false} in the + * config force-enables translation anywhere (useful for singleplayer testing). + */ +public final class HypixelDetector { + private HypixelDetector() { + } + + public static boolean isTranslationActive() { + ModConfig config = ModConfig.get(); + if (!config.enabled) { + return false; + } + if (!config.onlyOnHypixel) { + return true; + } + ServerData server = Minecraft.getInstance().getCurrentServer(); + if (server == null || server.ip == null) { + return false; + } + String ip = server.ip.toLowerCase(Locale.ROOT); + return ip.endsWith("hypixel.net") || ip.endsWith("hypixel.io"); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/mixin/AbstractContainerScreenMixin.java b/src/main/java/dev/laybalt/skyblocktranslator/mixin/AbstractContainerScreenMixin.java new file mode 100644 index 0000000..46319ff --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/mixin/AbstractContainerScreenMixin.java @@ -0,0 +1,59 @@ +package dev.laybalt.skyblocktranslator.mixin; + +import java.util.List; + +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +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.ModifyArg; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; + +import dev.laybalt.skyblocktranslator.config.ModConfig; +import dev.laybalt.skyblocktranslator.detect.HypixelDetector; +import dev.laybalt.skyblocktranslator.pipeline.TranslationEngine; + +/** + * Render-level translation of container screens — which is where all of + * SkyBlock's UI lives (its menus are chest GUIs). + * + *

Item tooltips: the line list returned by {@code getTooltipFromContainerItem} + * is rebuilt every frame, so replacing entries here is a pure display change — + * the underlying ItemStack stays untouched and other mods keep seeing English. + * + *

Screen titles: {@code extractLabels} draws the container title and the + * inventory label through {@code GuiGraphicsExtractor.text(...)}; swapping the + * component argument translates exactly what is drawn and nothing else. + */ +@Mixin(AbstractContainerScreen.class) +public abstract class AbstractContainerScreenMixin { + @Inject(method = "getTooltipFromContainerItem", at = @At("RETURN"), cancellable = true) + private void skyblockTranslator$translateTooltip(ItemStack stack, CallbackInfoReturnable> cir) { + if (!ModConfig.get().translateItems || !HypixelDetector.isTranslationActive()) { + return; + } + List lines = cir.getReturnValue(); + List translated = TranslationEngine.get().translateLines(lines); + if (translated != lines) { + cir.setReturnValue(translated); + } + } + + @ModifyArg( + method = "extractLabels", + 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$translateLabel(Component original) { + if (!ModConfig.get().translateMenus || !HypixelDetector.isTranslationActive()) { + return original; + } + return TranslationEngine.get().translate(original); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java new file mode 100644 index 0000000..01eefde --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/LegacyText.java @@ -0,0 +1,89 @@ +package dev.laybalt.skyblocktranslator.pipeline; + +import java.util.Map; +import java.util.Optional; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; + +/** + * Converts between {@link Component} trees and "legacy" strings with §-codes. + * + *

Hypixel text reaches the client either as literals with embedded §-codes or as + * styled component siblings. We flatten everything into one legacy string so the + * rest of the pipeline works on plain strings. The client still renders §-codes + * inside literal components, so the translated result can be a single literal. + */ +public final class LegacyText { + private static final char SECTION = '§'; + + /** Legacy color table: ARGB-less RGB value -> §-code char. */ + private static final Map COLOR_CODES = Map.ofEntries( + Map.entry(0x000000, '0'), Map.entry(0x0000AA, '1'), Map.entry(0x00AA00, '2'), + Map.entry(0x00AAAA, '3'), Map.entry(0xAA0000, '4'), Map.entry(0xAA00AA, '5'), + Map.entry(0xFFAA00, '6'), Map.entry(0xAAAAAA, '7'), Map.entry(0x555555, '8'), + Map.entry(0x5555FF, '9'), Map.entry(0x55FF55, 'a'), Map.entry(0x55FFFF, 'b'), + Map.entry(0xFF5555, 'c'), Map.entry(0xFF55FF, 'd'), Map.entry(0xFFFF55, 'e'), + Map.entry(0xFFFFFF, 'f')); + + private LegacyText() { + } + + /** Flattens a component tree into a single legacy string with §-codes. */ + public static String toLegacy(Component component) { + StringBuilder out = new StringBuilder(); + component.visit((style, text) -> { + out.append(codesFor(style)); + out.append(text); + return Optional.empty(); + }, Style.EMPTY); + return out.toString(); + } + + private static String codesFor(Style style) { + if (style.isEmpty()) { + return ""; + } + StringBuilder codes = new StringBuilder(); + TextColor color = style.getColor(); + if (color != null) { + Character code = COLOR_CODES.get(color.getValue() & 0xFFFFFF); + if (code != null) { + codes.append(SECTION).append(code); + } + } + if (style.isObfuscated()) codes.append(SECTION).append('k'); + if (style.isBold()) codes.append(SECTION).append('l'); + if (style.isStrikethrough()) codes.append(SECTION).append('m'); + if (style.isUnderlined()) codes.append(SECTION).append('n'); + if (style.isItalic()) codes.append(SECTION).append('o'); + return codes.toString(); + } + + /** Removes every §-code from a legacy string. */ + public static String stripCodes(String legacy) { + if (legacy.indexOf(SECTION) < 0) { + return legacy; + } + StringBuilder out = new StringBuilder(legacy.length()); + for (int i = 0; i < legacy.length(); i++) { + char c = legacy.charAt(i); + if (c == SECTION && i + 1 < legacy.length()) { + i++; // skip the code char + } else if (c != SECTION) { + out.append(c); + } + } + return out.toString(); + } + + /** Returns the run of §-codes at the very start of a legacy string ("§7§o" for "§7§oText"). */ + public static String leadingCodes(String legacy) { + int i = 0; + while (i + 1 < legacy.length() && legacy.charAt(i) == SECTION) { + i += 2; + } + return legacy.substring(0, i); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java new file mode 100644 index 0000000..04fe9bb --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/Normalizer.java @@ -0,0 +1,72 @@ +package dev.laybalt.skyblocktranslator.pipeline; + +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. + */ +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 Normalizer() { + } + + public record Template(String key, List 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()); + } + 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) { + if (args.isEmpty()) { + return translated; + } + StringBuilder out = new StringBuilder(translated.length()); + for (int i = 0; i < translated.length(); i++) { + char c = translated.charAt(i); + if (c == '{') { + int close = translated.indexOf('}', i); + if (close > i) { + try { + int idx = Integer.parseInt(translated, i + 1, close, 10); + if (idx >= 0 && idx < args.size()) { + out.append(args.get(idx)); + i = close; + continue; + } + } catch (NumberFormatException ignored) { + // not a placeholder, fall through + } + } + } + out.append(c); + } + return out.toString(); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java new file mode 100644 index 0000000..51a4135 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/pipeline/TranslationEngine.java @@ -0,0 +1,152 @@ +package dev.laybalt.skyblocktranslator.pipeline; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import net.minecraft.network.chat.Component; + +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.TranslationProvider; + +/** + * Render-side translation: takes the {@link Component} that is about to be drawn + * and returns a translated replacement, or the original when nothing matches. + * + *

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. + */ +public final class TranslationEngine { + private static final int MEMO_LIMIT = 4096; + + private static TranslationEngine instance; + + private final List providers; + private final LocalCacheProvider cache; + + /** Legacy string -> rendered component. Guarded by itself (render thread + safety). */ + private final Map memo = new LinkedHashMap<>(256, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MEMO_LIMIT; + } + }; + + private final Set dumped = ConcurrentHashMap.newKeySet(); + private final Path dumpFile; + + private TranslationEngine(String language) { + this.cache = new LocalCacheProvider(ModConfig.directory(), language); + this.providers = List.of(new DictionaryProvider(language), cache); + this.dumpFile = ModConfig.directory().resolve("untranslated-" + language + ".txt"); + if (Files.exists(dumpFile)) { + try { + dumped.addAll(Files.readAllLines(dumpFile, StandardCharsets.UTF_8)); + } catch (IOException e) { + SkyblockTranslatorClient.LOGGER.warn("Could not read {}", dumpFile, e); + } + } + } + + public static void init() { + instance = new TranslationEngine(ModConfig.get().language); + } + + public static TranslationEngine get() { + if (instance == null) { + init(); + } + return instance; + } + + public LocalCacheProvider cache() { + return cache; + } + + /** Translates one component; returns the original instance when there is no translation. */ + public Component translate(Component original) { + String legacy = LegacyText.toLegacy(original); + if (legacy.isBlank()) { + return original; + } + synchronized (memo) { + Component known = memo.get(legacy); + if (known != null) { + return known; + } + } + Component result = compute(legacy, original); + synchronized (memo) { + memo.put(legacy, result); + } + return result; + } + + /** Translates a tooltip line list; returns the same list instance when nothing changed. */ + public List translateLines(List lines) { + List out = null; + for (int i = 0; i < lines.size(); i++) { + Component original = lines.get(i); + Component translated = translate(original); + if (translated != original && out == null) { + out = new ArrayList<>(lines); + } + if (out != null) { + out.set(i, translated); + } + } + return out != null ? out : lines; + } + + private Component compute(String legacy, Component original) { + String plain = LegacyText.stripCodes(legacy); + if (plain.isBlank()) { + return original; + } + Normalizer.Template template = Normalizer.normalize(plain); + for (TranslationProvider provider : providers) { + String translation = provider.lookup(template.key()); + if (translation != null) { + String restored = Normalizer.restore(translation, template.args()); + return Component.literal(LegacyText.leadingCodes(legacy) + restored); + } + } + recordMissing(template.key()); + return original; + } + + private void recordMissing(String templateKey) { + if (!ModConfig.get().dumpUntranslated || !hasLetters(templateKey) || !dumped.add(templateKey)) { + return; + } + try { + Files.createDirectories(dumpFile.getParent()); + Files.writeString(dumpFile, templateKey + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException e) { + SkyblockTranslatorClient.LOGGER.warn("Could not append to {}", dumpFile, e); + } + } + + private static boolean hasLetters(String s) { + for (int i = 0; i < s.length(); i++) { + if (Character.isLetter(s.charAt(i))) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/providers/DictionaryProvider.java b/src/main/java/dev/laybalt/skyblocktranslator/providers/DictionaryProvider.java new file mode 100644 index 0000000..6454273 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/providers/DictionaryProvider.java @@ -0,0 +1,81 @@ +package dev.laybalt.skyblocktranslator.providers; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.jetbrains.annotations.Nullable; + +import dev.laybalt.skyblocktranslator.SkyblockTranslatorClient; + +/** + * Bundled, curated dictionaries shipped inside the mod jar. + * + *

Layout: {@code assets/skyblock-translator/dict//_index.json} lists the + * dictionary files of that language; each file is a flat + * {@code "english template" -> "translation"} JSON object. Listing files in an + * index avoids classpath directory enumeration and keeps loading trivial. + */ +public final class DictionaryProvider implements TranslationProvider { + private static final Gson GSON = new Gson(); + private static final TypeToken> MAP_TYPE = new TypeToken<>() { + }; + private static final TypeToken> LIST_TYPE = new TypeToken<>() { + }; + + private final Map entries = new HashMap<>(); + + public DictionaryProvider(String language) { + String base = "/assets/skyblock-translator/dict/" + language + "/"; + for (String file : readIndex(base)) { + try (Reader reader = open(base + file)) { + if (reader == null) { + SkyblockTranslatorClient.LOGGER.warn("Dictionary file listed in index but missing: {}{}", base, file); + continue; + } + Map loaded = GSON.fromJson(reader, MAP_TYPE); + if (loaded != null) { + entries.putAll(loaded); + } + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to load dictionary {}{}", base, file, e); + } + } + SkyblockTranslatorClient.LOGGER.info("Loaded {} dictionary entries for {}", entries.size(), language); + } + + private static java.util.List readIndex(String base) { + try (Reader reader = open(base + "_index.json")) { + if (reader == null) { + return java.util.List.of(); + } + java.util.List index = GSON.fromJson(reader, LIST_TYPE); + return index != null ? index : java.util.List.of(); + } catch (IOException | RuntimeException e) { + SkyblockTranslatorClient.LOGGER.error("Failed to read dictionary index {}", base, e); + return java.util.List.of(); + } + } + + @Nullable + private static Reader open(String resourcePath) { + InputStream in = DictionaryProvider.class.getResourceAsStream(resourcePath); + return in == null ? null : new InputStreamReader(in, StandardCharsets.UTF_8); + } + + @Override + @Nullable + public String lookup(String templateKey) { + return entries.get(templateKey); + } + + public int size() { + return entries.size(); + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/providers/LocalCacheProvider.java b/src/main/java/dev/laybalt/skyblocktranslator/providers/LocalCacheProvider.java new file mode 100644 index 0000000..80b97b0 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/providers/LocalCacheProvider.java @@ -0,0 +1,75 @@ +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.Map; +import java.util.concurrent.ConcurrentHashMap; + +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; + +/** + * Per-user translation cache persisted in the config directory. + * + *

Remote providers (free MT now, premium later) drop their results here so a + * string is only ever translated once. The file is plain JSON, so users can also + * hand-edit it to override or add translations. + */ +public final class LocalCacheProvider 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 ConcurrentHashMap<>(); + private volatile boolean dirty; + + public LocalCacheProvider(Path configDir, String language) { + this.file = configDir.resolve("cache-" + 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 translation cache {}", file, e); + } + } + } + + @Override + @Nullable + public String lookup(String templateKey) { + return entries.get(templateKey); + } + + public void put(String templateKey, String translation) { + entries.put(templateKey, translation); + dirty = true; + } + + /** Writes the cache back to disk if anything changed since the last save. */ + public void save() { + if (!dirty) { + return; + } + dirty = false; + 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 translation cache {}", file, e); + } + } +} diff --git a/src/main/java/dev/laybalt/skyblocktranslator/providers/TranslationProvider.java b/src/main/java/dev/laybalt/skyblocktranslator/providers/TranslationProvider.java new file mode 100644 index 0000000..15bbbc9 --- /dev/null +++ b/src/main/java/dev/laybalt/skyblocktranslator/providers/TranslationProvider.java @@ -0,0 +1,15 @@ +package dev.laybalt.skyblocktranslator.providers; + +import org.jetbrains.annotations.Nullable; + +/** + * A source of translations for normalized templates. + * + *

Providers are queried in order (dictionary → local cache → remote); the + * first non-null answer wins. Keys and values are placeholder templates, e.g. + * {@code "Damage: +{0}"} → {@code "Урон: +{0}"}. + */ +public interface TranslationProvider { + @Nullable + String lookup(String templateKey); +} diff --git a/src/main/resources/assets/skyblock-translator/dict/ru_ru/_index.json b/src/main/resources/assets/skyblock-translator/dict/ru_ru/_index.json new file mode 100644 index 0000000..59b26cd --- /dev/null +++ b/src/main/resources/assets/skyblock-translator/dict/ru_ru/_index.json @@ -0,0 +1,4 @@ +[ + "menus.json", + "stats.json" +] diff --git a/src/main/resources/assets/skyblock-translator/dict/ru_ru/menus.json b/src/main/resources/assets/skyblock-translator/dict/ru_ru/menus.json index b987d62..9166ccb 100644 --- a/src/main/resources/assets/skyblock-translator/dict/ru_ru/menus.json +++ b/src/main/resources/assets/skyblock-translator/dict/ru_ru/menus.json @@ -2,11 +2,32 @@ "SkyBlock Menu": "Меню SkyBlock", "Your SkyBlock Profile": "Ваш профиль SkyBlock", "Your Skills": "Ваши навыки", + "Skills": "Навыки", "Collections": "Коллекции", "Recipe Book": "Книга рецептов", "Fast Travel": "Быстрое перемещение", - "Damage: +{0}": "Урон: +{0}", - "Strength: +{0}": "Сила: +{0}", - "Health: +{0}": "Здоровье: +{0}", - "Defense: +{0}": "Защита: +{0}" + "Trades": "Обмены", + "Profile Management": "Управление профилями", + "Pets": "Питомцы", + "Storage": "Хранилище", + "Bank": "Банк", + "Auction House": "Аукцион", + "Bazaar": "Базар", + "Quest Log": "Журнал заданий", + "Calendar and Events": "Календарь и события", + "Booster Cookie": "Ускоряющее печенье", + "Settings": "Настройки", + "SkyBlock Levels": "Уровни SkyBlock", + "Inventory": "Инвентарь", + "Crafting": "Крафт", + "Craft Item": "Создать предмет", + "Anvil": "Наковальня", + "Enchant Item": "Зачаровать предмет", + "Reforge Item": "Перековать предмет", + "Click to view!": "Нажмите для просмотра!", + "Click to open!": "Нажмите, чтобы открыть!", + "Go Back": "Назад", + "Close": "Закрыть", + "Next Page": "Следующая страница", + "Previous Page": "Предыдущая страница" } 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 new file mode 100644 index 0000000..3a51669 --- /dev/null +++ b/src/main/resources/assets/skyblock-translator/dict/ru_ru/stats.json @@ -0,0 +1,32 @@ +{ + "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": "НЕОБЫЧНЫЙ", + "RARE": "РЕДКИЙ", + "EPIC": "ЭПИЧЕСКИЙ", + "LEGENDARY": "ЛЕГЕНДАРНЫЙ", + "MYTHIC": "МИФИЧЕСКИЙ", + "DIVINE": "БОЖЕСТВЕННЫЙ", + "SPECIAL": "ОСОБЫЙ", + "Right-click to view recipes!": "ПКМ — посмотреть рецепты!", + "This item can be reforged!": "Этот предмет можно перековать!", + "Requires Combat Skill {0}!": "Требуется навык Боя {0}!" +} diff --git a/src/main/resources/skyblock-translator.mixins.json b/src/main/resources/skyblock-translator.mixins.json index 0fe2652..5863d62 100644 --- a/src/main/resources/skyblock-translator.mixins.json +++ b/src/main/resources/skyblock-translator.mixins.json @@ -2,7 +2,9 @@ "required": true, "package": "dev.laybalt.skyblocktranslator.mixin", "compatibilityLevel": "JAVA_25", - "client": [], + "client": [ + "AbstractContainerScreenMixin" + ], "injectors": { "defaultRequire": 1 }, diff --git a/src/test/java/dev/laybalt/skyblocktranslator/pipeline/LegacyStringTest.java b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/LegacyStringTest.java new file mode 100644 index 0000000..7f7e731 --- /dev/null +++ b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/LegacyStringTest.java @@ -0,0 +1,22 @@ +package dev.laybalt.skyblocktranslator.pipeline; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Pure string-side tests of LegacyText (component conversion is covered in-game). */ +class LegacyStringTest { + @Test + void stripRemovesAllCodes() { + assertEquals("Damage: +100", LegacyText.stripCodes("§7Damage: §c+100")); + assertEquals("plain", LegacyText.stripCodes("plain")); + assertEquals("BoldRed", LegacyText.stripCodes("§l§4BoldRed")); + } + + @Test + void leadingCodesReturnsOnlyThePrefixRun() { + assertEquals("§7§o", LegacyText.leadingCodes("§7§oGray italic §ctext")); + assertEquals("", LegacyText.leadingCodes("no codes §7here")); + assertEquals("§a", LegacyText.leadingCodes("§aGreen")); + } +} diff --git a/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java new file mode 100644 index 0000000..2d78128 --- /dev/null +++ b/src/test/java/dev/laybalt/skyblocktranslator/pipeline/NormalizerTest.java @@ -0,0 +1,51 @@ +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.assertTrue; + +class NormalizerTest { + @Test + void plainStringWithoutNumbersIsUntouched() { + Normalizer.Template t = Normalizer.normalize("SkyBlock Menu"); + assertEquals("SkyBlock Menu", t.key()); + assertTrue(t.args().isEmpty()); + } + + @Test + void extractsSimpleNumber() { + Normalizer.Template t = Normalizer.normalize("Damage: +100"); + assertEquals("Damage: +{0}", t.key()); + assertEquals(List.of("100"), t.args()); + } + + @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()); + } + + @Test + void restoreSubstitutesPlaceholdersBack() { + Normalizer.Template t = Normalizer.normalize("Damage: +1,024"); + String restored = Normalizer.restore("Урон: +{0}", t.args()); + assertEquals("Урон: +1,024", restored); + } + + @Test + void restoreKeepsUnknownBracesLiteral() { + assertEquals("Урон: +{9} x", Normalizer.restore("Урон: +{9} x", List.of("100"))); + assertEquals("{нет} 100", Normalizer.restore("{нет} {0}", List.of("100"))); + } + + @Test + void roundTripNormalizeRestoreIsIdentity() { + String original = "Requires Combat Skill 25!"; + Normalizer.Template t = Normalizer.normalize(original); + assertEquals(original, Normalizer.restore(t.key(), t.args())); + } +}