Language.java 19.3 KB
Newer Older
1
2
3
4
5
package emu.grasscutter.utils;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import emu.grasscutter.Grasscutter;
AnimeGitB's avatar
AnimeGitB committed
6
7
import emu.grasscutter.data.GameData;
import emu.grasscutter.data.ResourceLoader;
Secretboy's avatar
Secretboy committed
8
import emu.grasscutter.game.player.Player;
AnimeGitB's avatar
AnimeGitB committed
9
10
11
12
13
14
15
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import lombok.EqualsAndHashCode;
16

17
import static emu.grasscutter.config.Configuration.*;
18
import static emu.grasscutter.utils.FileUtils.getResourcePath;
19

AnimeGitB's avatar
AnimeGitB committed
20
21
22
23
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
24
import java.io.InputStream;
AnimeGitB's avatar
AnimeGitB committed
25
26
27
28
29
30
31
32
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
Secretboy-SMR's avatar
Secretboy-SMR committed
33
import java.util.concurrent.ConcurrentHashMap;
AnimeGitB's avatar
AnimeGitB committed
34
35
36
37
38
39
40
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
41
42
43
import java.util.Map;

public final class Language {
44
    private static final Map<String, Language> cachedLanguages = new ConcurrentHashMap<>();
github-actions's avatar
github-actions committed
45

Secretboy's avatar
Secretboy committed
46
    private final String languageCode;
47
    private final Map<String, String> translations = new ConcurrentHashMap<>();
48
    private static boolean scannedTextmaps = false;  // Ensure that we don't infinitely rescan on cache misses that don't exist
49
50
51
52
53
54
55

    /**
     * Creates a language instance from a code.
     * @param langCode The language code.
     * @return A language instance.
     */
    public static Language getLanguage(String langCode) {
Secretboy-SMR's avatar
Secretboy-SMR committed
56
57
58
59
        if (cachedLanguages.containsKey(langCode)) {
            return cachedLanguages.get(langCode);
        }

60
61
        var fallbackLanguageCode = Utils.getLanguageCode(FALLBACK_LANGUAGE);
        var description = getLanguageFileDescription(langCode, fallbackLanguageCode);
62
        var actualLanguageCode = description.getLanguageCode();
Secretboy's avatar
Secretboy committed
63

64
65
66
        Language languageInst;
        if (description.getLanguageFile() != null) {
            languageInst = new Language(description);
Secretboy's avatar
Secretboy committed
67
            cachedLanguages.put(actualLanguageCode, languageInst);
68
        } else {
Secretboy's avatar
Secretboy committed
69
70
71
72
            languageInst = cachedLanguages.get(actualLanguageCode);
            cachedLanguages.put(langCode, languageInst);
        }

Secretboy-SMR's avatar
Secretboy-SMR committed
73
        return languageInst;
74
75
76
77
78
79
80
81
82
    }

    /**
     * Returns the translated value from the key while substituting arguments.
     * @param key The key of the translated value to return.
     * @param args The arguments to substitute.
     * @return A translated value with arguments substituted.
     */
    public static String translate(String key, Object... args) {
KingRainbow44's avatar
KingRainbow44 committed
83
        String translated = Grasscutter.getLanguage().get(key);
github-actions's avatar
github-actions committed
84

AnimeGitB's avatar
AnimeGitB committed
85
        for (int i = 0; i < args.length; i++) {
github-actions's avatar
github-actions committed
86
            args[i] = switch (args[i].getClass().getSimpleName()) {
AnimeGitB's avatar
AnimeGitB committed
87
88
89
90
91
92
                case "String" -> args[i];
                case "TextStrings" -> ((TextStrings) args[i]).get(0).replace("\\\\n", "\\n");  // TODO: Change this to server language
                default -> args[i].toString();
            };
        }

KingRainbow44's avatar
KingRainbow44 committed
93
94
95
96
97
98
        try {
            return translated.formatted(args);
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to format string: " + key, exception);
            return translated;
        }
99
100
    }

Secretboy's avatar
Secretboy committed
101
102
103
104
105
106
107
108
109
110
111
112
113
    /**
     * Returns the translated value from the key while substituting arguments.
     * @param player Target player
     * @param key The key of the translated value to return.
     * @param args The arguments to substitute.
     * @return A translated value with arguments substituted.
     */
    public static String translate(Player player, String key, Object... args) {
        if (player == null) {
            return translate(key, args);
        }

        var langCode = Utils.getLanguageCode(player.getAccount().getLocale());
114
        String translated = getLanguage(langCode).get(key);
github-actions's avatar
github-actions committed
115

AnimeGitB's avatar
AnimeGitB committed
116
        for (int i = 0; i < args.length; i++) {
github-actions's avatar
github-actions committed
117
            args[i] = switch (args[i].getClass().getSimpleName()) {
AnimeGitB's avatar
AnimeGitB committed
118
119
120
121
122
123
                case "String" -> args[i];
                case "TextStrings" -> ((TextStrings) args[i]).getGC(langCode).replace("\\\\n", "\n");  // Note that we don't unescape \n for server console
                default -> args[i].toString();
            };
        }

Secretboy's avatar
Secretboy committed
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
        try {
            return translated.formatted(args);
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to format string: " + key, exception);
            return translated;
        }
    }

    /**
     * get language code
     */
    public String getLanguageCode() {
        return languageCode;
    }

139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
    /**
     * Recursive helper function to flatten a Json tree
     * Converts input like {"foo": {"bar": "baz"}} to {"foo.bar": "baz"}
     * @param map The map to insert the keys into
     * @param key The flattened key of the current element
     * @param element The current element
     */
    private static void putFlattenedKey(Map<String,String> map, String key, JsonElement element) {
        if (element.isJsonObject()) {
            element.getAsJsonObject().entrySet().forEach(entry -> {
                String keyPrefix = key.isEmpty() ? "" : key + ".";
                putFlattenedKey(map, keyPrefix + entry.getKey(), entry.getValue());
            });
        } else {
            map.put(key, element.getAsString());
        }
    }

Secretboy's avatar
Secretboy committed
157
158
159
    /**
     * Reads a file and creates a language instance.
     */
160
161
    private Language(LanguageStreamDescription description) {
        languageCode = description.getLanguageCode();
github-actions's avatar
github-actions committed
162

Secretboy's avatar
Secretboy committed
163
        try {
164
165
            var object = JsonUtils.decode(Utils.readFromInputStream(description.getLanguageFile()), JsonObject.class);
            object.entrySet().forEach(entry -> putFlattenedKey(translations, entry.getKey(), entry.getValue()));
Secretboy's avatar
Secretboy committed
166
        } catch (Exception exception) {
167
            Grasscutter.getLogger().warn("Failed to load language file: " + description.getLanguageCode(), exception);
Secretboy's avatar
Secretboy committed
168
169
170
171
        }
    }

    /**
172
     * create a LanguageStreamDescription
Secretboy's avatar
Secretboy committed
173
174
175
     * @param languageCode The name of the language code.
     * @param fallbackLanguageCode The name of the fallback language code.
     */
176
    private static LanguageStreamDescription getLanguageFileDescription(String languageCode, String fallbackLanguageCode) {
Secretboy's avatar
Secretboy committed
177
178
        var fileName = languageCode + ".json";
        var fallback = fallbackLanguageCode + ".json";
github-actions's avatar
github-actions committed
179

180
        String actualLanguageCode = languageCode;
181
        InputStream file = Grasscutter.class.getResourceAsStream("/languages/" + fileName);
Secretboy's avatar
Secretboy committed
182

183
        if (file == null) { // Provided fallback language.
184
            Grasscutter.getLogger().warn("Failed to load language file: " + fileName + ", falling back to: " + fallback);
Secretboy's avatar
Secretboy committed
185
186
            actualLanguageCode = fallbackLanguageCode;
            if (cachedLanguages.containsKey(actualLanguageCode)) {
187
                return new LanguageStreamDescription(actualLanguageCode, null);
Secretboy's avatar
Secretboy committed
188
            }
github-actions's avatar
github-actions committed
189

190
            file = Grasscutter.class.getResourceAsStream("/languages/" + fallback);
191
        }
Secretboy's avatar
Secretboy committed
192

github-actions's avatar
github-actions committed
193
        if (file == null) { // Fallback the fallback language.
194
            Grasscutter.getLogger().warn("Failed to load language file: " + fallback + ", falling back to: en-US.json");
Secretboy's avatar
Secretboy committed
195
196
            actualLanguageCode = "en-US";
            if (cachedLanguages.containsKey(actualLanguageCode)) {
197
                return new LanguageStreamDescription(actualLanguageCode, null);
Secretboy's avatar
Secretboy committed
198
            }
github-actions's avatar
github-actions committed
199

200
            file = Grasscutter.class.getResourceAsStream("/languages/en-US.json");
201
        }
Secretboy's avatar
Secretboy committed
202

github-actions's avatar
github-actions committed
203
        if (file == null)
204
            throw new RuntimeException("Unable to load the primary, fallback, and 'en-US' language files.");
Secretboy's avatar
Secretboy committed
205

206
        return new LanguageStreamDescription(actualLanguageCode, file);
207
208
209
210
211
212
213
214
    }

    /**
     * Returns the value (as a string) from a nested key.
     * @param key The key to look for.
     * @return The value (as a string) from a nested key.
     */
    public String get(String key) {
215
        if (translations.containsKey(key)) return translations.get(key);
216
217
        String valueNotFoundPattern = "This value does not exist. Please report this to the Discord: ";
        String result = valueNotFoundPattern + key;
218
219
        if (!languageCode.equals("en-US")) {
            String englishValue = getLanguage("en-US").get(key);
220
221
222
223
            if (!englishValue.contains(valueNotFoundPattern)) {
                result += "\nhere is english version:\n" + englishValue;
            }
        }
224
        return result;
225
    }
Secretboy's avatar
Secretboy committed
226

227
228
229
    private static class LanguageStreamDescription {
        private final String languageCode;
        private final InputStream languageFile;
Secretboy's avatar
Secretboy committed
230

231
        public LanguageStreamDescription(String languageCode, InputStream languageFile) {
Secretboy's avatar
Secretboy committed
232
233
234
235
236
237
238
239
240
241
242
243
            this.languageCode = languageCode;
            this.languageFile = languageFile;
        }

        public String getLanguageCode() {
            return languageCode;
        }

        public InputStream getLanguageFile() {
            return languageFile;
        }
    }
AnimeGitB's avatar
AnimeGitB committed
244

245
    private static final int TEXTMAP_CACHE_VERSION = 0x9CCACE04;
AnimeGitB's avatar
AnimeGitB committed
246
247
    @EqualsAndHashCode public static class TextStrings implements Serializable {
        public static final String[] ARR_LANGUAGES = {"EN", "CHS", "CHT", "JP", "KR", "DE", "ES", "FR", "ID", "PT", "RU", "TH", "VI"};
AnimeGitB's avatar
AnimeGitB committed
248
        public static final String[] ARR_GC_LANGUAGES = {"en-US", "zh-CN", "zh-TW", "ja-JP", "ko-KR", "en-US", "es-ES", "fr-FR", "en-US", "en-US", "ru-RU", "en-US", "en-US"};  // TODO: Update the placeholder en-US entries if we ever add GC translations for the missing client languages
AnimeGitB's avatar
AnimeGitB committed
249
250
251
252
253
254
255
        public static final int NUM_LANGUAGES = ARR_LANGUAGES.length;
        public static final List<String> LIST_LANGUAGES = Arrays.asList(ARR_LANGUAGES);
        public static final Object2IntMap<String> MAP_LANGUAGES =  // Map "EN": 0, "CHS": 1, ..., "VI": 12
            new Object2IntOpenHashMap<>(
                IntStream.range(0, ARR_LANGUAGES.length)
                .boxed()
                .collect(Collectors.toMap(i -> ARR_LANGUAGES[i], i -> i)));
AnimeGitB's avatar
AnimeGitB committed
256
257
258
259
260
        public static final Object2IntMap<String> MAP_GC_LANGUAGES =  // Map "en-US": 0, "zh-CN": 1, ...
            new Object2IntOpenHashMap<>(
                IntStream.range(0, ARR_GC_LANGUAGES.length)
                .boxed()
                .collect(Collectors.toMap(i -> ARR_GC_LANGUAGES[i], i -> i, (i1, i2) -> i1)));  // Have to handle duplicates referring back to the first
AnimeGitB's avatar
AnimeGitB committed
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
        public String[] strings = new String[ARR_LANGUAGES.length];

        public TextStrings() {};

        public TextStrings(String init) {
            for (int i = 0; i < NUM_LANGUAGES; i++)
                this.strings[i] = init;
        };

        public TextStrings(List<String> strings, int key) {
            // Some hashes don't have strings for some languages :(
            String nullReplacement = "[N/A] %d".formatted((long) key & 0xFFFFFFFFL);
            for (int i = 0; i < NUM_LANGUAGES; i++) {  // Find first non-null if there is any
                String s = strings.get(i);
                if (s != null) {
                    nullReplacement = "[%s] - %s".formatted(ARR_LANGUAGES[i], s);
                    break;
                }
            }
            for (int i = 0; i < NUM_LANGUAGES; i++) {
                String s = strings.get(i);
                if (s != null)
                    this.strings[i] = s;
                else
                    this.strings[i] = nullReplacement;
            }
        }

289
290
291
292
293
294
295
296
        public static List<Language> getLanguages() {
            return Arrays.stream(ARR_GC_LANGUAGES).map(Language::getLanguage).toList();
        }

        public String get(int languageIndex) {
            return strings[languageIndex];
        }

AnimeGitB's avatar
AnimeGitB committed
297
298
299
300
        public String get(String languageCode) {
            return strings[MAP_LANGUAGES.getOrDefault(languageCode, 0)];
        }

AnimeGitB's avatar
AnimeGitB committed
301
302
303
304
        public String getGC(String languageCode) {
            return strings[MAP_GC_LANGUAGES.getOrDefault(languageCode, 0)];
        }

AnimeGitB's avatar
AnimeGitB committed
305
306
307
308
309
310
311
312
313
314
315
316
        public boolean set(String languageCode, String string) {
            int index = MAP_LANGUAGES.getOrDefault(languageCode, -1);
            if (index < 0) return false;
            strings[index] = string;
            return true;
        }
    }

    private static final Pattern textMapKeyValueRegex = Pattern.compile("\"(\\d+)\": \"(.+)\"");

    private static Int2ObjectMap<String> loadTextMapFile(String language, IntSet nameHashes) {
        Int2ObjectMap<String> output = new Int2ObjectOpenHashMap<>();
317
        try (BufferedReader file = Files.newBufferedReader(getResourcePath("TextMap/TextMap"+language+".json"), StandardCharsets.UTF_8)) {
AnimeGitB's avatar
AnimeGitB committed
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
            Matcher matcher = textMapKeyValueRegex.matcher("");
            return new Int2ObjectOpenHashMap<>(
                file.lines()
                    .sequential()
                    .map(matcher::reset)  // Side effects, but it's faster than making a new one
                    .filter(Matcher::find)
                    .filter(m -> nameHashes.contains((int) Long.parseLong(m.group(1))))  // TODO: Cache this parse somehow
                    .collect(Collectors.toMap(
                        m -> (int) Long.parseLong(m.group(1)),
                        m -> m.group(2).replace("\\\"", "\""))));
        } catch (Exception e) {
            Grasscutter.getLogger().error("Error loading textmap: " + language);
            Grasscutter.getLogger().error(e.toString());
        }
        return output;
    }

    private static Int2ObjectMap<TextStrings> loadTextMapFiles(IntSet nameHashes) {
        Map<Integer, Int2ObjectMap<String>> mapLanguageMaps =  // Separate step to process the textmaps in parallel
            TextStrings.LIST_LANGUAGES.parallelStream().collect(
            Collectors.toConcurrentMap(s -> TextStrings.MAP_LANGUAGES.getInt(s), s -> loadTextMapFile(s, nameHashes)));
github-actions's avatar
github-actions committed
339
        List<Int2ObjectMap<String>> languageMaps =
AnimeGitB's avatar
AnimeGitB committed
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
            IntStream.range(0, TextStrings.NUM_LANGUAGES)
            .mapToObj(i -> mapLanguageMaps.get(i))
            .collect(Collectors.toList());

        Map<TextStrings, TextStrings> canonicalTextStrings = new HashMap<>();
        return new Int2ObjectOpenHashMap<TextStrings>(
            nameHashes
            .intStream()
            .boxed()
            .collect(Collectors.toMap(key -> key, key -> {
                TextStrings t = new TextStrings(
                    IntStream.range(0, TextStrings.NUM_LANGUAGES)
                    .mapToObj(i -> languageMaps.get(i).get((int) key))
                    .collect(Collectors.toList()), (int) key);
                return canonicalTextStrings.computeIfAbsent(t, x -> t);
                }))
            );
    }

    private static Int2ObjectMap<TextStrings> loadTextMapsCache() throws Exception {
        try (ObjectInputStream file = new ObjectInputStream(new BufferedInputStream(Files.newInputStream(TEXTMAP_CACHE_PATH), 0x100000))) {
            final int fileVersion = file.readInt();
            if (fileVersion != TEXTMAP_CACHE_VERSION)
                throw new Exception("Invalid cache version");
            return (Int2ObjectMap<TextStrings>) file.readObject();
        }
    }

    private static void saveTextMapsCache(Int2ObjectMap<TextStrings> input) throws IOException {
        try {
            Files.createDirectory(Path.of("cache"));
        } catch (FileAlreadyExistsException ignored) {};
        try (ObjectOutputStream file = new ObjectOutputStream(new BufferedOutputStream(Files.newOutputStream(TEXTMAP_CACHE_PATH, StandardOpenOption.CREATE), 0x100000))) {
            file.writeInt(TEXTMAP_CACHE_VERSION);
            file.writeObject(input);
        }
    }

    private static Int2ObjectMap<TextStrings> textMapStrings;
    private static final Path TEXTMAP_CACHE_PATH = Path.of(Utils.toFilePath("cache/TextMapCache.bin"));

    public static Int2ObjectMap<TextStrings> getTextMapStrings() {
        if (textMapStrings == null)
            loadTextMaps();
        return textMapStrings;
    }

    public static TextStrings getTextMapKey(long hash) {
388
389
        int key = (int) hash;
        if ((textMapStrings == null) || (!scannedTextmaps && !textMapStrings.containsKey(key)))
AnimeGitB's avatar
AnimeGitB committed
390
            loadTextMaps();
391
        return textMapStrings.get(key);
AnimeGitB's avatar
AnimeGitB committed
392
393
394
395
396
397
398
    }

    public static void loadTextMaps() {
        // Check system timestamps on cache and resources
        try {
            long cacheModified = Files.getLastModifiedTime(TEXTMAP_CACHE_PATH).toMillis();

399
            long textmapsModified = Files.list(getResourcePath("TextMap"))
AnimeGitB's avatar
AnimeGitB committed
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
                .filter(path -> path.toString().endsWith(".json"))
                .map(path -> {
                    try {
                        return Files.getLastModifiedTime(path).toMillis();
                    } catch (Exception ignored) {
                        Grasscutter.getLogger().debug("Exception while checking modified time: ", path);
                        return Long.MAX_VALUE;  // Don't use cache, something has gone wrong
                    }
                })
                .max(Long::compare)
                .get();

                Grasscutter.getLogger().debug("Cache modified %d, textmap modified %d".formatted(cacheModified, textmapsModified));
            if (textmapsModified < cacheModified) {
                // Try loading from cache
                Grasscutter.getLogger().info("Loading cached TextMaps");
                textMapStrings = loadTextMapsCache();
                return;
            }
        } catch (Exception e) {
            Grasscutter.getLogger().debug("Exception while checking cache: ", e);
        };

        // Regenerate cache
        Grasscutter.getLogger().info("Generating TextMaps cache");
        ResourceLoader.loadAll();
        IntSet usedHashes = new IntOpenHashSet();
        GameData.getAvatarDataMap().forEach((k, v) -> usedHashes.add((int) v.getNameTextMapHash()));
AnimeGitB's avatar
AnimeGitB committed
428
429
        GameData.getAvatarSkillDataMap().forEach((k, v) -> {
            usedHashes.add((int) v.getNameTextMapHash());
430
            usedHashes.add((int) v.getDescTextMapHash());
AnimeGitB's avatar
AnimeGitB committed
431
        });
AnimeGitB's avatar
AnimeGitB committed
432
        GameData.getItemDataMap().forEach((k, v) -> usedHashes.add((int) v.getNameTextMapHash()));
433
        GameData.getHomeWorldBgmDataMap().forEach((k, v) -> usedHashes.add((int) v.getBgmNameTextMapHash()));
AnimeGitB's avatar
AnimeGitB committed
434
435
436
437
438
439
440
441
442
443
444
445
        GameData.getMonsterDataMap().forEach((k, v) -> usedHashes.add((int) v.getNameTextMapHash()));
        GameData.getMainQuestDataMap().forEach((k, v) -> usedHashes.add((int) v.getTitleTextMapHash()));
        GameData.getQuestDataMap().forEach((k, v) -> usedHashes.add((int) v.getDescTextMapHash()));
        // Incidental strings
        usedHashes.add((int) 4233146695L);  // Character
        usedHashes.add((int) 4231343903L);  // Weapon
        usedHashes.add((int)  332935371L);  // Standard Wish
        usedHashes.add((int) 2272170627L);  // Character Event Wish
        usedHashes.add((int) 3352513147L);  // Character Event Wish-2
        usedHashes.add((int) 2864268523L);  // Weapon Event Wish

        textMapStrings = loadTextMapFiles(usedHashes);
446
        scannedTextmaps = true;
AnimeGitB's avatar
AnimeGitB committed
447
448
449
450
451
452
        try {
            saveTextMapsCache(textMapStrings);
        } catch (IOException e) {
            Grasscutter.getLogger().error("Failed to save TextMap cache: ", e);
        };
    }
453
}