From 671155eecc95ea4d04efcf21840856df54be55ce Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 30 Sep 2026 00:46:52 +0900 Subject: [PATCH] zh quests: translate.lua builder + first 100 quest strings 40250 quests say gameforge.., loaded by game from share/locale//translate.lua at start. build_zh_translate_lua.py lays the reviewed data/zh_40250/quest_translate_zh.tsv over the English file line by line (nested tables kept), re-wraps at the quest window width, and writes GBK with 0x5C trail bytes doubled. Deployed on 203; blacksmith dialog shows Chinese. Co-Authored-By: Claude Opus 5.5 --- data/zh_40250/README.md | 15 +++ data/zh_40250/quest_translate_zh.tsv | 102 +++++++++++++++ scripts/build_zh_translate_lua.py | 174 +++++++++++++++++++++++++ scripts/test_build_zh_translate_lua.py | 41 ++++++ 4 files changed, 332 insertions(+) create mode 100644 data/zh_40250/quest_translate_zh.tsv create mode 100644 scripts/build_zh_translate_lua.py create mode 100644 scripts/test_build_zh_translate_lua.py diff --git a/data/zh_40250/README.md b/data/zh_40250/README.md index 7953e05c..6b2c6b20 100644 --- a/data/zh_40250/README.md +++ b/data/zh_40250/README.md @@ -16,3 +16,18 @@ `scripts/build_zh_locale.py` 构建时以 overlay 覆盖快照同 VNUM / 同键的值。 旧的龙驹中文表与线上表有 21 个相同 VNUM 不同名;不把旧表覆盖到服务端。 服务端名称若再更新,需重新只读导出并核对差异后更新本快照。 + +## 任务文本(translate.lua) + +40250 的任务文本都通过 `gameforge.<任务>.<键>` 引用。game 启动时从 `share/locale//translate.lua` 读取这些文本(questlua.cpp), +所以换文件后重启即可生效,不用重新编译 quest。 + +- 译文放在 `quest_translate_zh.tsv`,每行一条:`<任务>.<键>`、制表符、中文,UTF-8 编码。 + - 译文里的 `[ENTER]` 是有意的换行;构建时每行按 50 列(汉字算 2 列)重新折行。 + - 英文原文相同的其他键会共用同一条译文。 +- 还没有译文的键保留英文。 +- 构建:`python3 scripts/build_zh_translate_lua.py build <输出>`,输出 GBK 编码。 + - 构建逐行保留英文文件的结构(包括嵌套表),只替换字符串值。 + - GBK 双字节字符的第二个字节可能是 0x5C(反斜杠),构建时会把它写成两个。 +- 查看待译条目:`python3 scripts/build_zh_translate_lua.py todo <目录> --quests <命名空间,...>`。 +- 部署到 203:覆盖 `/usr/metin2/server/share/locale/newcibn/translate.lua`,然后重启。英文原件备份在同目录的 `translate.lua.en-20260930`。 diff --git a/data/zh_40250/quest_translate_zh.tsv b/data/zh_40250/quest_translate_zh.tsv new file mode 100644 index 00000000..b8a31c9d --- /dev/null +++ b/data/zh_40250/quest_translate_zh.tsv @@ -0,0 +1,102 @@ +# 40250 quest strings (translate.lua), key中文; [ENTER] = deliberate line break. scripts/build_zh_translate_lua.py +# 盛唐国=Shinsoo 秦皇国=Chunjo 汉武国=Jinno 两=Yang 帮会=Guild 龙神=Dragon God +arena_manager._100_say 你想跟自己打?随你便,不过那用不着比武场。[ENTER]等你认真起来了再来找我。 +arena_manager._10_npcChat 开始切磋比武 +arena_manager._110_say 你的切磋对手还没达到%s级,很抱歉,我不能允许这场比武。 +arena_manager._120_say 要完成切磋约定,%s必须来见我。 +arena_manager._130_say %s正在进行切磋比武。 +arena_manager._140_sayTitle 决定 +arena_manager._150_say %s不想和你切磋比武。 +arena_manager._160_say 出了点问题,请稍后再报名。 +arena_manager._170_say 你的切磋对手已经在比武中了,请稍后再来! +arena_manager._180_say 比武场正在使用中,请稍后再报名。 +arena_manager._190_npcChat 观看切磋比武 +arena_manager._195_say %s 对 %s +arena_manager._200_tableInsert 关闭 +arena_manager._20_sayTitle 乐师: +arena_manager._210_say 现在没有进行中的切磋比武。 +arena_manager._220_say 现在有%s场切磋比武正在进行。 +arena_manager._30_say 比武场在一场恶战中被彻底毁坏了。修好之前不能进行切磋比武。 +arena_manager._40_say 已经有别人报名了切磋比武。 +arena_manager._50_say 没到%s级就不能报名切磋比武。 +arena_manager._60_say 你想报名切磋比武?那就告诉我你对手的名字。 +arena_manager._70_say 你还不知道要跟谁打吗?不告诉我名字,我就没法给你报名。 +arena_manager._80_say 好的,%s。你已报名与%s切磋比武。 +arena_manager._85_say %s正在等待%s接受挑战。 +arena_manager._87_say %s,你准备好与%s切磋比武了吗? +arena_manager._90_say %s现在不在线。请稍后再试,或者和你的对手约好时间! +blacksmith._10_npcChat 我要升级物品。 +blacksmith._20_sayTitle 铁匠 +blacksmith._30_say 你好![ENTER]我负责升级物品。想升级的话,把物品拿来给我就行。 +blacksmith._40_sayTitle 说明: +blacksmith._50_sayReward 把物品从背包拖到铁匠身上即可。 +buy_fishrod._100_sayTitle 注意: +buy_fishrod._10_npcChat 鱼竿特卖 +buy_fishrod._110_say 感谢购买鱼竿套装。[ENTER]本店不仅有特价商品,还为新手举办钓鱼大赛!头奖人人都想要……新手武器+3!!只要把5条死鲫鱼交给渔夫,马上就能领到这份大奖。机会难得,快来挑战吧! +buy_fishrod._120_select 参加 +buy_fishrod._130_select 不理会 +buy_fishrod._150_makequestbutton 钓鱼大赛 +buy_fishrod._160_say 钓到鲫鱼后点击它把它杀死。[ENTER]把5条死鲫鱼交给渔夫就能领到奖励。 +buy_fishrod._170_npcChat 领取奖品 +buy_fishrod._180_say 你带来5条死鲫鱼了?太厉害了![ENTER]说好的,这是你的新手武器+3。好好用吧!欢迎再来! +buy_fishrod._190_say 抱歉,只有带来5条死鲫鱼才能领奖。再试试吧! +buy_fishrod._200_makequestbutton 钓鱼大赛奖品 +buy_fishrod._20_sayTitle 渔夫: +buy_fishrod._220_say 你参加了钓鱼大赛,并把5条死鲫鱼交给了渔夫。作为奖励,你将得到一个精美的箱子! +buy_fishrod._230_say 箱子里有一把新手武器+3。 +buy_fishrod._240_say 因为你没有带来5条死鲫鱼,箱子是空的。 +buy_fishrod._30_say 超值价格!手柄精美的鱼竿!还附赠100个鱼饵和20条蚯蚓!我们「鱼竿大师」为顾客提供三合一鱼竿套装,只要3980两。[ENTER]要买鱼竿套装吗? +buy_fishrod._40_select 购买 +buy_fishrod._50_select 不买 +buy_fishrod._60_say 你买下了三合一鱼竿套装,祝你玩得开心!请留意附带的钓鱼大赛说明。回头见! +buy_fishrod._70_say 抱歉,你的钱不够。别担心,很快还会有机会的。回头见! +buy_fishrod._80_say 未知按钮 [%s] +buy_fishrod._90_makequestbutton 钓鱼大赛说明 +change_empire._100_say 已婚玩家不能更换国家,只有未婚玩家才能更换。 +change_empire._10_npcChat 更换国家: +change_empire._110_say 想更换国家就得先离婚。 +change_empire._120_sayReward 变身状态下不能更换国家。 +change_empire._130_say 请解除变身后再使用这个物品。 +change_empire._140_sayReward 帮会成员不能更换国家。 +change_empire._150_say 退出帮会后才能使用这个物品。 +change_empire._160_sayReward 你的钱不够,不能更换国家。 +change_empire._170_say 至少有50万两才能使用这个物品。 +change_empire._180_say 你想换到哪个国家? +change_empire._190_select 盛唐国 +change_empire._200_select 秦皇国 +change_empire._205_select 汉武国 +change_empire._20_say 请等待国家更换完成。 +change_empire._210_sayReward 真的要更换国家吗?一旦更换就无法撤销。 +change_empire._230_sayReward 请完全退出游戏后重新登录,以完成更换。 +change_empire._250_say 你选的就是你现在所在的国家。你不是想换国家吗? +change_empire._260_say 你已选择了国家。[ENTER]你目前是某个帮会的成员,请注意这个帮会不会跟你去新的国家。 +change_empire._270_say 你已选择了国家。[ENTER]你目前已婚,请注意你的配偶不会跟你去新的国家。 +change_empire._30_say 更换国家需要满足以下条件:[ENTER]1. 不是帮会成员。[ENTER]2. 未婚。[ENTER]3. 没有结婚的打算。[ENTER]4. 需要50万两。[ENTER]要更换国家吗? +change_empire._40_say 使用这个物品可以更换国家。 +change_empire._50_sayTitle 你不能使用这个物品! +change_empire._60_sayReward 只能更换一次国家。 +change_empire._70_say GM:要把时间清零吗? +change_empire._80_say 所需时间已清零。 +change_empire._90_sayReward 已婚玩家不能更换国家。 +change_guild_master._100_say 所选角色经验不足,不能领导帮会。[ENTER](至少40级) +change_guild_master._10_say 只有帮主才能使用这个物品。 +change_guild_master._110_say 这个角色担任领导职务还太早。 +change_guild_master._20_say 请输入接替你担任帮主的人的名字。 +change_guild_master._30_say 输入无效。 +change_guild_master._50_say 帮会里没有这个玩家。 +change_guild_master._60_say 完成了! +change_guild_master._70_say 你不在帮会里。 +change_guild_master._80_say 你已经是帮主了。 +change_guild_master._90_say 未来的帮主必须在线才能接任! +charge_cash_by_voucher._010_syschat %s个龙币已充入你的账户。 +check_collect_reward._010_npcChat 生物学者无效的秘方 +check_collect_reward._020_say_title 医生: +check_collect_reward._030_say 你好!有什么我能帮你的吗? +check_collect_reward._040_say 是关于生物学者梁柏年的奖励。[ENTER]还记得我给你带来的秘方吗?其中一种药水对我没起作用。 +check_collect_reward._050_say 嗯,真没想到!这个配方少了一味药。不过别担心,我这里东西齐全,能马上熬一副新药,给你想要的效果。稍等一下…… +check_collect_reward._060_say 好了,给你。深吸一口气,喝下去。很好。我这就去找梁柏年,告诉他这个错误。保重,回头见! +check_trans_ticket._100_say 等你准备好把一生献给龙神,也许就能被教团接纳。 +check_trans_ticket._10_npcChat 前往秘密洞穴 +check_trans_ticket._110_say 你竟敢对抗龙神?也许你能打败我,但你永远逃不过神的怒火! +check_trans_ticket._120_say 等你准备好把一生献给龙神,自然会找到正确的道路。去吧,愿你平安! +check_trans_ticket._130_1_select 交出血石 diff --git a/scripts/build_zh_translate_lua.py b/scripts/build_zh_translate_lua.py new file mode 100644 index 00000000..8fd84f8c --- /dev/null +++ b/scripts/build_zh_translate_lua.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Build a Chinese translate.lua for the 40250 quest system. + +The 40250 quests say `gameforge..`; game loads these strings from +share/locale//translate.lua when it starts (questlua.cpp), so a new +translate.lua only needs a restart, not a quest recompile. + +data/zh_40250/quest_translate_zh.tsv holds the reviewed translations, one +`.\\t` row per string, UTF-8. `[ENTER]` in a translation is a +deliberate line break; each line is re-wrapped to the quest window width +(CJK characters count double). A key without a row keeps its English text, and a +row fills every key whose English text equals the translated key's. + + build_zh_translate_lua.py build GBK, for the server + build_zh_translate_lua.py todo [--quests a,b] [--chunk N] + untranslated unique strings +""" + +import argparse +import re +import unicodedata +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +DEFAULT_EN = REPO.parent / "40250/Server Client TMP4/Server/metin2/server/share/locale/english/translate.lua" +DEFAULT_TABLE = REPO / "data/zh_40250/quest_translate_zh.tsv" +ROW = re.compile(r'^gameforge\.(\S+)\s*=\s*"(.*)"\s*$') +WIDTH = 50 # columns; the English lines are wrapped at about 50 characters + + +def english_rows(path: Path): + """[(key, text)] in file order; the file is cp1252.""" + rows = [] + for line in path.read_bytes().decode("cp1252").splitlines(): + match = ROW.match(line) + if match: + rows.append((match.group(1), match.group(2))) + return rows + + +def table_rows(path: Path): + rows = {} + if path.is_file(): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line or line.startswith("#"): + continue + key, sep, text = line.partition("\t") + if not sep: + raise ValueError(f"{path}:{number}: no tab") + rows[key] = text + return rows + + +def columns(char: str) -> int: + return 2 if unicodedata.east_asian_width(char) in "WF" else 1 + + +def wrap(line: str, width: int = WIDTH): + """Break a line at the width; CJK text breaks anywhere, ASCII words and %s stay whole.""" + tokens = re.findall(r"%[-+ #0-9.]*[a-zA-Z]|[A-Za-z0-9_'.,!?:;()/-]+|\s+|.", line) + lines, current, used = [], "", 0 + for token in tokens: + size = sum(columns(c) for c in token) + if used + size > width and current.strip(): + lines.append(current.rstrip()) + current, used = "", 0 + if token.isspace(): + continue + current += token + used += size + lines.append(current) + return lines + + +def layout(text: str) -> str: + return "[ENTER]".join(part for line in text.split("[ENTER]") for part in wrap(line)) + + +# English text left untranslated, in characters GBK has no code for. +ASCII = str.maketrans({"\u2018": "'", "\u2019": "'", "\u00b4": "'", "\u201c": "'", "\u201d": "'", + "\u2013": "-", "\u2014": "-", "\u2026": "...", "\u00a0": " "}) + + +def english_text(text: str) -> str: + text = text.translate(ASCII) + return "".join(c if c.isascii() else "?" for c in text) + + +def translations(english, table): + """key -> Chinese text for every key, by key or by an equal English text.""" + by_text = {} + for key, text in english: + if key in table: + by_text.setdefault(text, table[key]) + return {key: table.get(key, by_text.get(text)) for key, text in english} + + +def build(en: Path, table_path: Path, output: Path): + english = english_rows(en) + table = table_rows(table_path) + unknown = sorted(set(table) - {key for key, _ in english}) + if unknown: + raise ValueError(f"keys not in translate.lua: {unknown[:10]}") + chinese = translations(english, table) + lines = [] + done = 0 + # The English file line by line (it also creates the nested tables), with the values replaced. + for line in en.read_bytes().decode("cp1252").splitlines(): + match = ROW.match(line) + if line.startswith("exportTestForCharset"): + lines.append('exportTestForCharset = "\u4e2d\u6587 "'.encode("gbk")) + continue + if not match: + lines.append(english_text(line).encode("ascii")) + continue + key, text = match.groups() + if chinese[key] is not None: + # Keep the English leading/trailing spaces: quests join some strings with names. + lead = text[: len(text) - len(text.lstrip(" "))] + trail = text[len(text.rstrip(" ")):] + body = layout(chinese[key].strip(" ")) + if '"' in body or "\\" in body: + raise ValueError(f"{key}: no quotes or backslashes in a translation") + # A GBK trail byte can be 0x5C, which Lua reads as an escape; doubled it stays one byte. + value = lead.encode() + body.encode("gbk").replace(b"\\", b"\\\\") + trail.encode() + done += 1 + else: + value = english_text(text).encode("ascii") # Lua source as in the English file + lines.append(f"gameforge.{key} = \"".encode("ascii") + value + b'"') + output.write_bytes(b"\n".join(lines) + b"\n") + return done, len(english) + + +def todo(en: Path, table_path: Path, out_dir: Path, quests=None, chunk=120): + english = english_rows(en) + chinese = translations(english, table_rows(table_path)) + seen, pending = set(), [] + for key, text in english: + if chinese[key] is None and text not in seen and (not quests or key.split(".")[0] in quests): + seen.add(text) + pending.append((key, text)) + out_dir.mkdir(parents=True, exist_ok=True) + for old in out_dir.glob("todo_*.tsv"): + old.unlink() + for index in range(0, len(pending), chunk): + part = pending[index:index + chunk] + (out_dir / f"todo_{index // chunk:03d}.tsv").write_text( + "".join(f"{key}\t{text}\n" for key, text in part), encoding="utf-8") + return len(pending) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--english", type=Path, default=DEFAULT_EN) + parser.add_argument("--table", type=Path, default=DEFAULT_TABLE) + sub = parser.add_subparsers(dest="command", required=True) + build_parser = sub.add_parser("build") + build_parser.add_argument("output", type=Path) + todo_parser = sub.add_parser("todo") + todo_parser.add_argument("out_dir", type=Path) + todo_parser.add_argument("--quests") + todo_parser.add_argument("--chunk", type=int, default=120) + args = parser.parse_args() + if args.command == "build": + done, total = build(args.english, args.table, args.output) + print(f"{args.output}: {done} of {total} strings in Chinese") + else: + quests = set(args.quests.split(",")) if args.quests else None + print(f"{todo(args.english, args.table, args.out_dir, quests, args.chunk)} strings to translate") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_build_zh_translate_lua.py b/scripts/test_build_zh_translate_lua.py new file mode 100644 index 00000000..33e2d683 --- /dev/null +++ b/scripts/test_build_zh_translate_lua.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Safeguards for the Chinese translate.lua build.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +SPEC = importlib.util.spec_from_file_location("build_zh_translate_lua", + Path(__file__).with_name("build_zh_translate_lua.py")) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class TranslateLuaTest(unittest.TestCase): + def test_build(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + en, table, out = root / "translate.lua", root / "table.tsv", root / "out.lua" + en.write_bytes( + b'exportTestForCharset = "\xfc "\ngameforge = {}\ngameforge.q = {}\ngameforge.q.sub = {}\n' + b'gameforge.q._10_say = "Hello[ENTER]there. "\n' + b'gameforge.q._20_say = "Hello[ENTER]there. "\n' + b'gameforge.q.sub._30_say = "Let\xb4s say \\"hi\\" "\n' + b'gameforge.q._40_say = "Long "\n') + # 乗 is 0x81 0x5C in GBK. + table.write_text("# comment\nq._10_say\t你好。\nq._40_say\t" + "长" * 30 + "乗\n", encoding="utf-8") + done, total = MODULE.build(en, table, out) + self.assertEqual((done, total), (3, 4)) + lines = out.read_bytes().split(b"\n") + self.assertEqual(lines[3], b"gameforge.q.sub = {}") + self.assertEqual(lines[4], 'gameforge.q._10_say = "你好。 "'.encode("gbk")) + self.assertEqual(lines[5], lines[4].replace(b"_10_", b"_20_")) # same English text + self.assertEqual(lines[6], b'gameforge.q.sub._30_say = "Let\'s say \\"hi\\" "') + wrapped = ("长" * 25 + "[ENTER]" + "长" * 5).encode("gbk") + b"\x81\\\\" + self.assertEqual(lines[7], b'gameforge.q._40_say = "' + wrapped + b' "') + + +if __name__ == "__main__": + unittest.main()