From 0da28b42eb2d7a7cd2959b0273bc07fba3565add Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 6 Apr 2026 20:28:08 +0200 Subject: [PATCH] feat(i18n): add Chinese (zh) language support --- CHANGELOG.md | 3 +- config.reference.json | 2 +- config/config.go | 2 +- server/channelserver/lang_en.go | 4 +- server/channelserver/lang_es.go | 4 +- server/channelserver/lang_fr.go | 4 +- server/channelserver/lang_jp.go | 4 +- server/channelserver/lang_zh.go | 104 ++++++++++++++++++++++ server/channelserver/sys_language.go | 4 +- server/channelserver/sys_language_test.go | 4 +- 10 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 server/channelserver/lang_zh.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b0fe7c7fe..ec2edf125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Server-side multi-language support ([#188](https://github.com/Mezeporta/Erupe/issues/188)): each player picks their own language with `!lang `, persisted per user (migration `0022_user_language`) and loaded on login. Chat replies, guild invite mails, and cafe/timer broadcasts are served in that language via `Session.I18n()`. Quest and scenario JSON text fields now accept either a plain string (unchanged) or a `{"jp":"...","en":"...","fr":"..."}` map; the compiler resolves per session and the quest cache is keyed by `(questID, lang)`. Existing single-language JSONs and `.bin` round-trips remain byte-identical. Shift-JIS wire encoding still applies (ASCII/kana/CJK only). Raviente world-wide broadcasts stay on the server default since they have no single session. +- Chinese (`zh`) language strings for chat commands, guild mails, cafe/timer broadcasts and prayer beads. Note: Shift-JIS wire encoding only covers characters shared with Japanese — simplified-only glyphs may fail to encode. +- Server-side multi-language support ([#188](https://github.com/Mezeporta/Erupe/issues/188)): each player picks their own language with `!lang `, persisted per user (migration `0022_user_language`) and loaded on login. Chat replies, guild invite mails, and cafe/timer broadcasts are served in that language via `Session.I18n()`. Quest and scenario JSON text fields now accept either a plain string (unchanged) or a `{"jp":"...","en":"...","fr":"..."}` map; the compiler resolves per session and the quest cache is keyed by `(questID, lang)`. Existing single-language JSONs and `.bin` round-trips remain byte-identical. Shift-JIS wire encoding still applies (ASCII/kana/CJK only). Raviente world-wide broadcasts stay on the server default since they have no single session. ### Changed diff --git a/config.reference.json b/config.reference.json index 110ea03e7..8ca907b00 100644 --- a/config.reference.json +++ b/config.reference.json @@ -189,7 +189,7 @@ }, { "Name": "Language", "Enabled": true, - "Description": "Show or change your preferred language (en|jp|fr|es)", + "Description": "Show or change your preferred language (en|jp|fr|es|zh)", "Prefix": "lang" } ], diff --git a/config/config.go b/config/config.go index 0cc19b5c0..f64fa7d12 100644 --- a/config/config.go +++ b/config/config.go @@ -446,7 +446,7 @@ func registerDefaults() { {Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"}, {Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"}, {Name: "Playtime", Enabled: true, Description: "Show your playtime", Prefix: "playtime"}, - {Name: "Language", Enabled: true, Description: "Show or change your preferred language (en|jp|fr|es)", Prefix: "lang"}, + {Name: "Language", Enabled: true, Description: "Show or change your preferred language (en|jp|fr|es|zh)", Prefix: "lang"}, }) // Courses diff --git a/server/channelserver/lang_en.go b/server/channelserver/lang_en.go index ed190c23d..3c5d7519f 100644 --- a/server/channelserver/lang_en.go +++ b/server/channelserver/lang_en.go @@ -39,8 +39,8 @@ func langEnglish() i18n { i.commands.timer.enabled = "Quest timer enabled" i.commands.timer.disabled = "Quest timer disabled" - i.commands.lang.usage = "Usage: %s " - i.commands.lang.invalid = "Unknown language %q. Supported: en, jp, fr, es" + i.commands.lang.usage = "Usage: %s " + i.commands.lang.invalid = "Unknown language %q. Supported: en, jp, fr, es, zh" i.commands.lang.success = "Language set to %s" i.commands.lang.current = "Current language: %s" diff --git a/server/channelserver/lang_es.go b/server/channelserver/lang_es.go index 231c7a2ea..4f3875d89 100644 --- a/server/channelserver/lang_es.go +++ b/server/channelserver/lang_es.go @@ -39,8 +39,8 @@ func langSpanish() i18n { i.commands.timer.enabled = "Temporizador de misión activado" i.commands.timer.disabled = "Temporizador de misión desactivado" - i.commands.lang.usage = "Uso: %s " - i.commands.lang.invalid = "Idioma desconocido %q. Compatibles: en, jp, fr, es" + i.commands.lang.usage = "Uso: %s " + i.commands.lang.invalid = "Idioma desconocido %q. Compatibles: en, jp, fr, es, zh" i.commands.lang.success = "Idioma establecido en %s" i.commands.lang.current = "Idioma actual: %s" diff --git a/server/channelserver/lang_fr.go b/server/channelserver/lang_fr.go index bdc7d251d..ac572b28e 100644 --- a/server/channelserver/lang_fr.go +++ b/server/channelserver/lang_fr.go @@ -39,8 +39,8 @@ func langFrench() i18n { i.commands.timer.enabled = "Minuteur de quête activé" i.commands.timer.disabled = "Minuteur de quête désactivé" - i.commands.lang.usage = "Utilisation : %s " - i.commands.lang.invalid = "Langue inconnue %q. Prises en charge : en, jp, fr, es" + i.commands.lang.usage = "Utilisation : %s " + i.commands.lang.invalid = "Langue inconnue %q. Prises en charge : en, jp, fr, es, zh" i.commands.lang.success = "Langue définie sur %s" i.commands.lang.current = "Langue actuelle : %s" diff --git a/server/channelserver/lang_jp.go b/server/channelserver/lang_jp.go index 3d039edd6..3b4b90168 100644 --- a/server/channelserver/lang_jp.go +++ b/server/channelserver/lang_jp.go @@ -39,8 +39,8 @@ func langJapanese() i18n { i.commands.timer.enabled = "クエストタイマーが有効になりました" i.commands.timer.disabled = "クエストタイマーが無効になりました" - i.commands.lang.usage = "使い方: %s " - i.commands.lang.invalid = "未対応の言語 %q。対応言語: en, jp, fr, es" + i.commands.lang.usage = "使い方: %s " + i.commands.lang.invalid = "未対応の言語 %q。対応言語: en, jp, fr, es, zh" i.commands.lang.success = "言語を %s に設定しました" i.commands.lang.current = "現在の言語: %s" diff --git a/server/channelserver/lang_zh.go b/server/channelserver/lang_zh.go new file mode 100644 index 000000000..2881e5a13 --- /dev/null +++ b/server/channelserver/lang_zh.go @@ -0,0 +1,104 @@ +package channelserver + +func langChinese() i18n { + var i i18n + + i.language = "中文" + i.cafe.reset = "重置于 %d/%d" + i.timer = "时间:%02d:%02d:%02d.%03d (%df)" + + i.commands.noOp = "您没有使用此命令的权限" + i.commands.disabled = "%s 命令已禁用" + i.commands.reload = "正在重新加载玩家..." + i.commands.playtime = "游戏时间:%d 小时 %d 分钟 %d 秒" + + i.commands.kqf.get = "KQF:%x" + i.commands.kqf.set.error = "命令错误。格式:%s set xxxxxxxxxxxxxxxx" + i.commands.kqf.set.success = "已设置 KQF,请切换区域/世界" + i.commands.kqf.version = "此命令在 MHFG10 之前已禁用" + i.commands.rights.error = "命令错误。格式:%s x" + i.commands.rights.success = "设置权限整数:%d" + i.commands.course.error = "命令错误。格式:%s <名称>" + i.commands.course.disabled = "%s 课程已禁用" + i.commands.course.enabled = "%s 课程已启用" + i.commands.course.locked = "%s 课程已锁定" + i.commands.teleport.error = "命令错误。格式:%s x y" + i.commands.teleport.success = "传送至 %d %d" + i.commands.psn.error = "命令错误。格式:%s " + i.commands.psn.success = "已连接 PSN ID:%s" + i.commands.psn.exists = "该 PSN ID 已连接到其他账户!" + + i.commands.discord.success = "您的 Discord 令牌:%s" + + i.commands.ban.noUser = "找不到用户" + i.commands.ban.success = "已成功封禁 %s" + i.commands.ban.invalid = "角色 ID 无效" + i.commands.ban.error = "命令错误。格式:%s [时长]" + i.commands.ban.length = " 直到 %s" + + i.commands.timer.enabled = "任务计时器已启用" + i.commands.timer.disabled = "任务计时器已禁用" + + i.commands.lang.usage = "用法:%s " + i.commands.lang.invalid = "未知语言 %q。支持的语言:en, jp, fr, es, zh" + i.commands.lang.success = "语言已设置为 %s" + i.commands.lang.current = "当前语言:%s" + + i.commands.ravi.noCommand = "未指定 Raviente 命令!" + i.commands.ravi.start.success = "大讨伐战即将开始" + i.commands.ravi.start.error = "大讨伐战已经开始!" + i.commands.ravi.multiplier = "Raviente 倍率当前为 %.2fx" + i.commands.ravi.res.success = "正在发送复活支援!" + i.commands.ravi.res.error = "尚未请求复活支援!" + i.commands.ravi.sed.success = "若有请求则发送镇静支援!" + i.commands.ravi.request = "请求镇静支援!" + i.commands.ravi.error = "无法识别的 Raviente 命令!" + i.commands.ravi.noPlayers = "无人参加大讨伐战!" + i.commands.ravi.version = "此命令在 MHFZZ 以外已禁用" + + i.raviente.berserk = "<大讨伐战:狂暴> 正在进行!" + i.raviente.extreme = "<大讨伐战:极限> 正在进行!" + i.raviente.extremeLimited = "<大讨伐战:极限(限定)> 正在进行!" + i.raviente.berserkSmall = "<大讨伐战:狂暴(小型)> 正在进行!" + + i.guild.rookieGuildName = "新人猎团 %d" + i.guild.returnGuildName = "回归猎团 %d" + + i.guild.invite.title = "邀请!" + i.guild.invite.body = "您已被邀请加入\n「%s」\n是否接受?" + + i.guild.invite.success.title = "成功!" + i.guild.invite.success.body = "您已成功加入\n「%s」。" + + i.guild.invite.accepted.title = "已接受" + i.guild.invite.accepted.body = "对方已接受您加入\n「%s」的邀请。" + + i.guild.invite.rejected.title = "已拒绝" + i.guild.invite.rejected.body = "您拒绝了加入\n「%s」的邀请。" + + i.guild.invite.declined.title = "已婉拒" + i.guild.invite.declined.body = "对方婉拒了您加入\n「%s」的邀请。" + + i.beads = []Bead{ + {1, "风暴之珠", "蕴含风暴之力的祈祷珠。\n召唤狂风助益同伴。"}, + {3, "斩击之珠", "蕴含斩击之力的祈祷珠。\n增强同伴的斩击力。"}, + {4, "活力之珠", "蕴含活力的祈祷珠。\n提升周围同伴的生命值。"}, + {8, "治愈之珠", "蕴含治愈之力的祈祷珠。\n以恢复能量守护同伴。"}, + {9, "狂怒之珠", "蕴含狂怒能量的祈祷珠。\n以战斗怒火激励同伴。"}, + {10, "瘴气之珠", "蕴含瘴气的祈祷珠。\n为同伴注入毒性之力。"}, + {11, "力量之珠", "蕴含原始力量的祈祷珠。\n赋予同伴压倒性的力量。"}, + {14, "雷鸣之珠", "蕴含闪电的祈祷珠。\n为同伴充填电力。"}, + {15, "寒冰之珠", "蕴含酷寒的祈祷珠。\n赋予同伴冰属性之力。"}, + {17, "烈火之珠", "蕴含灼热的祈祷珠。\n以烈火属性点燃同伴。"}, + {18, "流水之珠", "蕴含流水的祈祷珠。\n赋予同伴水属性之力。"}, + {19, "神龙之珠", "蕴含龙之能量的祈祷珠。\n赋予同伴龙属性之力。"}, + {20, "大地之珠", "蕴含大地之力的祈祷珠。\n以大地属性稳固同伴。"}, + {21, "疾风之珠", "蕴含疾风的祈祷珠。\n提升同伴的敏捷。"}, + {22, "光辉之珠", "蕴含光辉的祈祷珠。\n以光明能量鼓舞同伴。"}, + {23, "暗影之珠", "蕴含黑暗的祈祷珠。\n为同伴注入暗影之力。"}, + {24, "铁壁之珠", "蕴含钢铁之力的祈祷珠。\n为同伴强化防御。"}, + {25, "免疫之珠", "蕴含封印之力的祈祷珠。\n消除同伴的属性弱点。"}, + } + + return i +} diff --git a/server/channelserver/sys_language.go b/server/channelserver/sys_language.go index 577d9c1f7..2eda4e520 100644 --- a/server/channelserver/sys_language.go +++ b/server/channelserver/sys_language.go @@ -141,7 +141,7 @@ func (i *i18n) beadDescription(beadType int) string { // supportedLangs lists the language codes the server can serve. Kept in one // place so the !lang command validator and future API handlers stay in sync // with getLangStringsFor. -var supportedLangs = []string{"en", "jp", "fr", "es"} +var supportedLangs = []string{"en", "jp", "fr", "es", "zh"} // isSupportedLang reports whether the given code is one the server can serve. func isSupportedLang(code string) bool { @@ -166,6 +166,8 @@ func getLangStringsFor(lang string) i18n { return langFrench() case "es": return langSpanish() + case "zh": + return langChinese() case "en": return langEnglish() default: diff --git a/server/channelserver/sys_language_test.go b/server/channelserver/sys_language_test.go index 17d8f617f..4bcf57596 100644 --- a/server/channelserver/sys_language_test.go +++ b/server/channelserver/sys_language_test.go @@ -128,6 +128,7 @@ func TestGetLangStringsFor(t *testing.T) { {"jp", "日本語", false, ""}, {"fr", "Français", true, ""}, {"es", "Español", true, ""}, + {"zh", "中文", true, ""}, {"", "English", true, ""}, {"xx", "English", true, ""}, } @@ -145,7 +146,7 @@ func TestGetLangStringsFor(t *testing.T) { } func TestIsSupportedLang(t *testing.T) { - for _, code := range []string{"en", "jp", "fr", "es"} { + for _, code := range []string{"en", "jp", "fr", "es", "zh"} { if !isSupportedLang(code) { t.Errorf("isSupportedLang(%q) = false, want true", code) } @@ -186,6 +187,7 @@ func TestLangCompleteness(t *testing.T) { "jp": langJapanese(), "fr": langFrench(), "es": langSpanish(), + "zh": langChinese(), } for code, lang := range languages { t.Run(code, func(t *testing.T) {