itemdesc_translate_zh.tsv maps the English description/summary text to Chinese (1,788 entries, every itemdesc row covered). build_zh_locale.py replaces the cells and wraps Chinese into 26-byte lines joined by ' |', which 40250's Western SplitDescription turns into forced line breaks; the 50307 overlay row is wrapped the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
9.8 KiB
Python
166 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Focused safeguards for the 40250/Longju locale merge."""
|
|
|
|
import importlib.util
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
SPEC = importlib.util.spec_from_file_location("build_zh_locale", Path(__file__).with_name("build_zh_locale.py"))
|
|
MODULE = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(MODULE)
|
|
|
|
|
|
def split_description(desc, limit):
|
|
"""uitooltip.SplitDescription (40250 root), which runs on the encoded bytes under Python 2."""
|
|
line_tokens, line_len, lines = [], 0, []
|
|
for token in desc.split():
|
|
if b"|" in token:
|
|
sep_pos = token.find(b"|")
|
|
line_tokens.append(token[:sep_pos])
|
|
lines.append(b" ".join(line_tokens))
|
|
line_len = len(token) - (sep_pos + 1)
|
|
line_tokens = [token[sep_pos + 1:]]
|
|
else:
|
|
line_len += len(token)
|
|
if len(line_tokens) + line_len > limit:
|
|
lines.append(b" ".join(line_tokens))
|
|
line_len = len(token)
|
|
line_tokens = [token]
|
|
else:
|
|
line_tokens.append(token)
|
|
if line_tokens:
|
|
lines.append(b" ".join(line_tokens))
|
|
return lines
|
|
|
|
|
|
class LocaleBuildTest(unittest.TestCase):
|
|
def test_preserves_40250_keys_formats_and_proto(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
en, zh, client, output = (root / name for name in ("en", "zh", "client", "out/Client"))
|
|
for path in (en, zh, client):
|
|
path.mkdir()
|
|
(en / "locale_game.txt").write_bytes(b"GOOD\tValue %d\tSA\nBAD\t%s at %d\tSA\nONLY_EN\tFallback\n")
|
|
(zh / "locale_game.txt").write_bytes("GOOD\t中文 %d\nBAD\t中文 %d 和 %s\n".encode("gbk"))
|
|
(en / "locale_interface.txt").write_bytes(b"ACCEPT\tAccept\n")
|
|
(zh / "locale_interface.txt").write_bytes("ACCEPT\t确定\n".encode("gbk"))
|
|
(en / "item_proto").write_bytes(b"40250-proto")
|
|
(zh / "item_proto").write_bytes(b"old-proto")
|
|
for path, atlas in ((en, b"en-atlas"), (zh, b"zh-atlas")):
|
|
(path / "ui/windows").mkdir(parents=True)
|
|
(path / "ui/windows/windows.dds").write_bytes(atlas)
|
|
(zh / "ui/windows/zh_only.sub").write_bytes(b"sub")
|
|
(client / "pack").mkdir()
|
|
(client / "pack/Index").write_bytes(b"PACK\r\nlocale/de/\r\nlocale_de\r\nlocale/en/\r\nlocale_en\r\n")
|
|
(client / "pack/locale_en.eix").write_bytes(b"en")
|
|
# Stands in for eterpack_pack: records its arguments as the .eix.
|
|
pack_tool = root / "eterpack_pack"
|
|
pack_tool.write_text("#!/bin/sh\necho \"$2 $3\" > \"$1.eix\"\n")
|
|
pack_tool.chmod(0o755)
|
|
|
|
report_path = MODULE.build(en, zh, output, client, pack_tool=pack_tool)
|
|
staged = output.parent / "locale-src/zh"
|
|
merged = (staged / "locale_game.txt").read_bytes().decode("gbk")
|
|
self.assertIn("GOOD\t中文 %d\tSA", merged)
|
|
self.assertIn("BAD\t%s at %d\tSA", merged)
|
|
self.assertIn("ONLY_EN\tFallback", merged)
|
|
self.assertEqual((staged / "item_proto").read_bytes(), b"40250-proto")
|
|
self.assertEqual((staged / "ui/windows/windows.dds").read_bytes(), b"zh-atlas")
|
|
self.assertFalse((staged / "ui/windows/zh_only.sub").exists())
|
|
self.assertEqual((output / "locale.cfg").read_text(), "10002 936 zh\n")
|
|
self.assertEqual((output / "locale_zh.cfg").read_text(), "10002 936 zh\n")
|
|
# A pack/ of its own: original packs linked, locale_zh packed from the staging dir and registered.
|
|
self.assertFalse((output / "pack").is_symlink())
|
|
self.assertFalse((output / "locale/zh").exists())
|
|
self.assertTrue((output / "pack/locale_en.eix").is_symlink())
|
|
self.assertEqual((output / "pack/locale_zh.eix").read_text(), f"{staged} locale/zh/\n")
|
|
self.assertEqual((output / "pack/Index").read_bytes(),
|
|
b"PACK\r\nlocale/de/\r\nlocale_de\r\nlocale/zh/\r\nlocale_zh\r\n"
|
|
b"locale/en/\r\nlocale_en\r\n")
|
|
self.assertEqual((client / "pack/Index").read_bytes()[:4], b"PACK")
|
|
self.assertFalse((client / "pack/locale_zh.eix").exists())
|
|
report = json.loads(report_path.read_text())
|
|
self.assertEqual(report["files"]["locale_game.txt"]["incompatible_format_keys"], ["BAD"])
|
|
|
|
# Reviewed overlay: fills an English-only key and replaces a format-incompatible Longju value.
|
|
names = root / "names"
|
|
names.mkdir()
|
|
(names / "locale_game_zh_overlay.txt").write_text("KEY\tVALUE\nBAD\t%s 于 %d\nONLY_EN\t备用\n")
|
|
report_path = MODULE.build(en, zh, output, client, names_dir=names, pack_tool=pack_tool)
|
|
merged = (staged / "locale_game.txt").read_bytes().decode("gbk")
|
|
self.assertIn("BAD\t%s 于 %d\tSA", merged)
|
|
self.assertIn("ONLY_EN\t备用", merged)
|
|
game = json.loads(report_path.read_text())["files"]["locale_game.txt"]
|
|
self.assertEqual((game["english_fallback_keys"], game["incompatible_format_keys"]), ([], []))
|
|
|
|
def test_name_overlay_replaces_snapshot_rows(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "names.txt").write_text("VNUM\tLOCALE_NAME\n1\tYang\n2\t剑\n")
|
|
(root / "overlay.txt").write_text("VNUM\tLOCALE_NAME\n1\t金币\n99\t不存在\n")
|
|
result = MODULE.convert_names(root / "names.txt", root / "out.txt", root / "overlay.txt")
|
|
self.assertEqual((root / "out.txt").read_bytes().decode("gbk"), "1\t金币\n2\t剑\n")
|
|
self.assertEqual(result["overlaid"], 1)
|
|
|
|
def test_row_overlay_replaces_whole_rows(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "itemdesc.txt").write_bytes(b"50306\tBook\tCaf\xe9.\r\n50307\tBook\tEasy.\r\n")
|
|
(root / "overlay.txt").write_text("# vnum\tname\tdescription\n50307\t礼包\t打开。\n")
|
|
result = MODULE.overlay_rows(root / "itemdesc.txt", root / "overlay.txt")
|
|
self.assertEqual((root / "itemdesc.txt").read_bytes(),
|
|
b"50306\tBook\tCaf\xe9.\r\n" + "50307\t礼包\t打开。\r\n".encode("gbk"))
|
|
self.assertEqual(result, {"replaced_rows": ["50307"]})
|
|
(root / "overlay.txt").write_text("99\tx\ty\n")
|
|
with self.assertRaises(ValueError):
|
|
MODULE.overlay_rows(root / "itemdesc.txt", root / "overlay.txt")
|
|
|
|
def test_itemdesc_translation_wraps_for_split_description(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
(root / "itemdesc.txt").write_bytes(
|
|
b"27001\tRed Potion (S)\tRestores HP.\t\r\n"
|
|
b"27002\tRed Potion (M)\tRestores 1,000 HP. Item is tradeable.\tPotion production\r\n"
|
|
b"99\tNew\tCaf\xe9.\t\r\n")
|
|
(root / "table.tsv").write_text(
|
|
"# English\t中文\nRestores HP.\t恢复生命值。\n"
|
|
"Restores 1,000 HP. Item is tradeable.\t立即恢复1,000点生命值,此物品可交易,冷却时间结束后可再次使用。\n"
|
|
"Potion production\t药水制作\n")
|
|
result = MODULE.translate_itemdesc(root / "itemdesc.txt", root / "table.tsv")
|
|
rows = (root / "itemdesc.txt").read_bytes().decode("gbk").split("\r\n")
|
|
self.assertEqual(rows[0], "27001\tRed Potion (S)\t恢复生命值。\t")
|
|
wrapped = rows[1].split("\t")[2]
|
|
self.assertEqual(wrapped, "立即恢复1,000点生命值, |此物品可交易, |冷却时间结束后可再次使用。")
|
|
self.assertEqual(rows[1].split("\t")[3], "药水制作")
|
|
self.assertEqual(rows[2], "99\tNew\tCafé.\t")
|
|
self.assertEqual(result, {"translated_cells": 3, "english_rows": ["99"]})
|
|
# uitooltip.SplitDescription (40250, Western path, 35 columns) on the GBK bytes gives these lines.
|
|
lines = split_description(wrapped.encode("gbk"), 35)
|
|
self.assertEqual([line.strip().decode("gbk") for line in lines],
|
|
["立即恢复1,000点生命值,", "此物品可交易,", "冷却时间结束后可再次使用。"])
|
|
self.assertEqual(MODULE.wrap_description("Plain English text"), "Plain English text")
|
|
|
|
def test_skilldesc_keeps_format_arguments(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
blank = [""] * 22
|
|
en_row = ["20", "WARRIOR", "Sword Strike", "Bolt", "Hurl", "Attack.", "Long-Range", "Multi", "Faint"] + blank
|
|
en_row[17], en_row[20] = "Attack Power %.0f-%.0f", "Faint Chance %.0f%%"
|
|
zh_row = ["20", "WARRIOR", "剑风", "斩气决", "剑气冲霄", "快速挥动 |打击敌人", "远程攻击", "", "击晕"] + blank
|
|
zh_row[17] = "攻击力 %.0f-%.0f"
|
|
(root / "en.txt").write_bytes(("\t".join(en_row) + "\n").encode("cp1252"))
|
|
(root / "zh.txt").write_bytes(("\t".join(zh_row) + "\n").encode("gbk"))
|
|
(root / "overlay.txt").write_text("VNUM\tCOLUMN\tTEXT\n20\t7\t多目标攻击\n20\t20\t气绝 %.0f\n")
|
|
result = MODULE.merge_skilldesc(root / "en.txt", root / "zh.txt", root / "out.txt", root / "overlay.txt")
|
|
row = (root / "out.txt").read_bytes().decode("gbk").rstrip("\n").split("\t")
|
|
self.assertEqual(row[2:9], ["剑风", "斩气决", "剑气冲霄", "快速挥动 |打击敌人", "远程攻击", "多目标攻击", "击晕"])
|
|
self.assertEqual((row[17], row[20]), ("攻击力 %.0f-%.0f", "Faint Chance %.0f%%"))
|
|
self.assertEqual(result["incompatible_format_cells"], ["20:20"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|