zh quests: translate.lua builder + first 100 quest strings
40250 quests say gameforge.<quest>.<key>, loaded by game from share/locale/<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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3ae77c3530
commit
671155eecc
@@ -16,3 +16,18 @@
|
||||
`scripts/build_zh_locale.py` 构建时以 overlay 覆盖快照同 VNUM / 同键的值。
|
||||
旧的龙驹中文表与线上表有 21 个相同 VNUM 不同名;不把旧表覆盖到服务端。
|
||||
服务端名称若再更新,需重新只读导出并核对差异后更新本快照。
|
||||
|
||||
## 任务文本(translate.lua)
|
||||
|
||||
40250 的任务文本都通过 `gameforge.<任务>.<键>` 引用。game 启动时从 `share/locale/<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`。
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 40250 quest strings (translate.lua), key<TAB>中文; [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 交出血石
|
||||
|
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a Chinese translate.lua for the 40250 quest system.
|
||||
|
||||
The 40250 quests say `gameforge.<quest>.<key>`; game loads these strings from
|
||||
share/locale/<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
|
||||
`<quest>.<key>\\t<text>` 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 <out translate.lua> GBK, for the server
|
||||
build_zh_translate_lua.py todo <dir> [--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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user