Language.java 19 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 javax.annotation.Nullable;
18
19

import static emu.grasscutter.config.Configuration.*;
20
import static emu.grasscutter.utils.FileUtils.getResourcePath;
21

AnimeGitB's avatar
AnimeGitB committed
22
23
24
25
26
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
27
import java.io.InputStream;
AnimeGitB's avatar
AnimeGitB committed
28
29
30
31
32
33
34
35
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
36
import java.util.concurrent.ConcurrentHashMap;
AnimeGitB's avatar
AnimeGitB committed
37
38
39
40
41
42
43
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;
44
45
46
import java.util.Map;

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

49
    private final JsonObject languageData;
Secretboy's avatar
Secretboy committed
50
    private final String languageCode;
Secretboy-SMR's avatar
Secretboy-SMR committed
51
    private final Map<String, String> cachedTranslations = new ConcurrentHashMap<>();
52
53
54
55
56
57
58

    /**
     * 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
59
60
61
62
        if (cachedLanguages.containsKey(langCode)) {
            return cachedLanguages.get(langCode);
        }

63
64
        var fallbackLanguageCode = Utils.getLanguageCode(FALLBACK_LANGUAGE);
        var description = getLanguageFileDescription(langCode, fallbackLanguageCode);
65
        var actualLanguageCode = description.getLanguageCode();
Secretboy's avatar
Secretboy committed
66

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

Secretboy-SMR's avatar
Secretboy-SMR committed
76
        return languageInst;
77
78
79
80
81
82
83
84
85
    }

    /**
     * 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
86
        String translated = Grasscutter.getLanguage().get(key);
github-actions's avatar
github-actions committed
87

AnimeGitB's avatar
AnimeGitB committed
88
        for (int i = 0; i < args.length; i++) {
github-actions's avatar
github-actions committed
89
            args[i] = switch (args[i].getClass().getSimpleName()) {
AnimeGitB's avatar
AnimeGitB committed
90
91
92
93
94
95
                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
96
97
98
99
100
101
        try {
            return translated.formatted(args);
        } catch (Exception exception) {
            Grasscutter.getLogger().error("Failed to format string: " + key, exception);
            return translated;
        }
102
103
    }

Secretboy's avatar
Secretboy committed
104
105
106
107
108
109
110
111
112
113
114
115
116
    /**
     * 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());
117
        String translated = getLanguage(langCode).get(key);
github-actions's avatar
github-actions committed
118

AnimeGitB's avatar
AnimeGitB committed
119
        for (int i = 0; i < args.length; i++) {
github-actions's avatar
github-actions committed
120
            args[i] = switch (args[i].getClass().getSimpleName()) {
AnimeGitB's avatar
AnimeGitB committed
121
122
123
124
125
126
                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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
        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;
    }

Secretboy's avatar
Secretboy committed
142
143
144
    /**
     * Reads a file and creates a language instance.
     */
145
    private Language(LanguageStreamDescription description) {
Secretboy's avatar
Secretboy committed
146
        @Nullable JsonObject languageData = null;
147
        languageCode = description.getLanguageCode();
github-actions's avatar
github-actions committed
148

Secretboy's avatar
Secretboy committed
149
        try {
150
            languageData = JsonUtils.decode(Utils.readFromInputStream(description.getLanguageFile()), JsonObject.class);
Secretboy's avatar
Secretboy committed
151
        } catch (Exception exception) {
152
            Grasscutter.getLogger().warn("Failed to load language file: " + description.getLanguageCode(), exception);
Secretboy's avatar
Secretboy committed
153
        }
github-actions's avatar
github-actions committed
154

Secretboy's avatar
Secretboy committed
155
156
157
158
        this.languageData = languageData;
    }

    /**
159
     * create a LanguageStreamDescription
Secretboy's avatar
Secretboy committed
160
161
162
     * @param languageCode The name of the language code.
     * @param fallbackLanguageCode The name of the fallback language code.
     */
163
    private static LanguageStreamDescription getLanguageFileDescription(String languageCode, String fallbackLanguageCode) {
Secretboy's avatar
Secretboy committed
164
165
        var fileName = languageCode + ".json";
        var fallback = fallbackLanguageCode + ".json";
github-actions's avatar
github-actions committed
166

167
        String actualLanguageCode = languageCode;
168
        InputStream file = Grasscutter.class.getResourceAsStream("/languages/" + fileName);
Secretboy's avatar
Secretboy committed
169

170
        if (file == null) { // Provided fallback language.
171
            Grasscutter.getLogger().warn("Failed to load language file: " + fileName + ", falling back to: " + fallback);
Secretboy's avatar
Secretboy committed
172
173
            actualLanguageCode = fallbackLanguageCode;
            if (cachedLanguages.containsKey(actualLanguageCode)) {
174
                return new LanguageStreamDescription(actualLanguageCode, null);
Secretboy's avatar
Secretboy committed
175
            }
github-actions's avatar
github-actions committed
176

177
            file = Grasscutter.class.getResourceAsStream("/languages/" + fallback);
178
        }
Secretboy's avatar
Secretboy committed
179

github-actions's avatar
github-actions committed
180
        if (file == null) { // Fallback the fallback language.
181
            Grasscutter.getLogger().warn("Failed to load language file: " + fallback + ", falling back to: en-US.json");
Secretboy's avatar
Secretboy committed
182
183
            actualLanguageCode = "en-US";
            if (cachedLanguages.containsKey(actualLanguageCode)) {
184
                return new LanguageStreamDescription(actualLanguageCode, null);
Secretboy's avatar
Secretboy committed
185
            }
github-actions's avatar
github-actions committed
186

187
            file = Grasscutter.class.getResourceAsStream("/languages/en-US.json");
188
        }
Secretboy's avatar
Secretboy committed
189

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

193
        return new LanguageStreamDescription(actualLanguageCode, file);
194
195
196
197
198
199
200
201
    }

    /**
     * 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) {
github-actions's avatar
github-actions committed
202
        if (this.cachedTranslations.containsKey(key)) {
203
204
            return this.cachedTranslations.get(key);
        }
github-actions's avatar
github-actions committed
205

206
207
208
209
        String[] keys = key.split("\\.");
        JsonObject object = this.languageData;

        int index = 0;
210
211
212
        String valueNotFoundPattern = "This value does not exist. Please report this to the Discord: ";
        String result = valueNotFoundPattern + key;
        boolean isValueFound = false;
213
214

        while (true) {
github-actions's avatar
github-actions committed
215
216
            if (index == keys.length) break;

217
            String currentKey = keys[index++];
github-actions's avatar
github-actions committed
218
            if (object.has(currentKey)) {
219
                JsonElement element = object.get(currentKey);
github-actions's avatar
github-actions committed
220
                if (element.isJsonObject())
221
222
                    object = element.getAsJsonObject();
                else {
223
                    isValueFound = true;
224
225
226
227
                    result = element.getAsString(); break;
                }
            } else break;
        }
228
229

        if (!isValueFound && !languageCode.equals("en-US")) {
230
            var englishValue = getLanguage("en-US").get(key);
231
232
233
234
            if (!englishValue.contains(valueNotFoundPattern)) {
                result += "\nhere is english version:\n" + englishValue;
            }
        }
github-actions's avatar
github-actions committed
235

236
237
        this.cachedTranslations.put(key, result); return result;
    }
Secretboy's avatar
Secretboy committed
238

239
240
241
    private static class LanguageStreamDescription {
        private final String languageCode;
        private final InputStream languageFile;
Secretboy's avatar
Secretboy committed
242

243
        public LanguageStreamDescription(String languageCode, InputStream languageFile) {
Secretboy's avatar
Secretboy committed
244
245
246
247
248
249
250
251
252
253
254
255
            this.languageCode = languageCode;
            this.languageFile = languageFile;
        }

        public String getLanguageCode() {
            return languageCode;
        }

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

257
    private static final int TEXTMAP_CACHE_VERSION = 0x9CCACE03;
AnimeGitB's avatar
AnimeGitB committed
258
259
    @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
260
        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
261
262
263
264
265
266
267
        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
268
269
270
271
272
        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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
        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;
            }
        }

301
302
303
304
305
306
307
308
        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
309
310
311
312
        public String get(String languageCode) {
            return strings[MAP_LANGUAGES.getOrDefault(languageCode, 0)];
        }

AnimeGitB's avatar
AnimeGitB committed
313
314
315
316
        public String getGC(String languageCode) {
            return strings[MAP_GC_LANGUAGES.getOrDefault(languageCode, 0)];
        }

AnimeGitB's avatar
AnimeGitB committed
317
318
319
320
321
322
323
324
325
326
327
328
        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<>();
329
        try (BufferedReader file = Files.newBufferedReader(getResourcePath("TextMap/TextMap"+language+".json"), StandardCharsets.UTF_8)) {
AnimeGitB's avatar
AnimeGitB committed
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
            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
351
        List<Int2ObjectMap<String>> languageMaps =
AnimeGitB's avatar
AnimeGitB committed
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
388
389
390
391
392
393
394
395
396
397
398
399
            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) {
AnimeGitB's avatar
AnimeGitB committed
400
401
        if (textMapStrings == null)
            loadTextMaps();
AnimeGitB's avatar
AnimeGitB committed
402
403
404
405
406
407
408
409
        return textMapStrings.get((int) hash);
    }

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

410
            long textmapsModified = Files.list(getResourcePath("TextMap"))
AnimeGitB's avatar
AnimeGitB committed
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
                .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
439
440
        GameData.getAvatarSkillDataMap().forEach((k, v) -> {
            usedHashes.add((int) v.getNameTextMapHash());
441
            usedHashes.add((int) v.getDescTextMapHash());
AnimeGitB's avatar
AnimeGitB committed
442
        });
AnimeGitB's avatar
AnimeGitB committed
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
        GameData.getItemDataMap().forEach((k, v) -> usedHashes.add((int) v.getNameTextMapHash()));
        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);
        try {
            saveTextMapsCache(textMapStrings);
        } catch (IOException e) {
            Grasscutter.getLogger().error("Failed to save TextMap cache: ", e);
        };
    }
462
}