feat: complete Chinese tutorial text and layout validation
This commit is contained in:
+21
-1
@@ -20,6 +20,15 @@ FORMAT = re.compile(r'%%|%[-+ #0]*[\d*]*(?:\.[\d*]+)?[hlL]?[diouxXeEfgGcs%]')
|
||||
CONTROLS = re.compile(r'[\a\b\f\n\r\t\v]')
|
||||
|
||||
|
||||
def format_tokens(text):
|
||||
# "speed 0% is ..." is prose, not the printf conversion "% i".
|
||||
# Only exclude a numeric percent whose apparent conversion cuts a word.
|
||||
return [m.group() for m in FORMAT.finditer(text)
|
||||
if not (m.start() > 0 and text[m.start() - 1].isdigit()
|
||||
and m.end() < len(text) and text[m.end()].isascii()
|
||||
and text[m.end()].isalpha() and ' ' in m.group())]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Literal:
|
||||
start: int
|
||||
@@ -121,6 +130,17 @@ def extract_display_literals(source: bytes, encoding: str = 'latin1') -> list[Li
|
||||
if len(arg) == 1 and arg[0].kind == 'string':
|
||||
lit = arg[0]
|
||||
found.append(Literal(lit.start, lit.end, lit.value, token.value, number))
|
||||
elif (len(arg) == 3 and [t.kind for t in arg] == ['string', 'name', 'string']
|
||||
and arg[1].value == 'F'
|
||||
and arg[0].value.startswith('\fTo use a charged spell select it')
|
||||
and arg[0].value.endswith('pressing the ')
|
||||
and arg[2].value.startswith(' KEY marked under it')):
|
||||
# Stock English Tutorial.dsc has unescaped quotes around F in
|
||||
# this specific display argument. Keep the complete text span;
|
||||
# do not generalize to expressions or other malformed strings.
|
||||
found.append(Literal(arg[0].start, arg[2].end,
|
||||
arg[0].value + '"F"' + arg[2].value,
|
||||
token.value, number))
|
||||
return sorted(found, key=lambda literal: literal.start)
|
||||
|
||||
|
||||
@@ -150,7 +170,7 @@ def patch_display_calls(source: bytes, translations: dict[str, str], *, allow_al
|
||||
translated_controls = ['ALIGN' if c in '\a\b\f\v' else c for c in translated_controls]
|
||||
if original_controls != translated_controls:
|
||||
raise ValueError('control sequence changed: %r' % literal.text)
|
||||
if FORMAT.findall(literal.text) != FORMAT.findall(translated):
|
||||
if format_tokens(literal.text) != format_tokens(translated):
|
||||
raise ValueError('format sequence changed: %r' % literal.text)
|
||||
replacements.append((literal.start, literal.end, encode_literal(translated)))
|
||||
result = source
|
||||
|
||||
@@ -3,6 +3,21 @@ from script_text import extract_display_literals, patch_display_calls
|
||||
|
||||
|
||||
class ScriptTextTests(unittest.TestCase):
|
||||
def test_numeric_percent_in_prose_is_not_a_printf_conversion(self):
|
||||
source = b'SetMissionDescription("speed 0% is pause; %s %d");'
|
||||
result = patch_display_calls(source, {'speed 0% is pause; %s %d': '速度 0% 等于暂停;%s %d'})
|
||||
self.assertIn('速度 0%'.encode('gb2312'), result)
|
||||
with self.assertRaises(ValueError):
|
||||
patch_display_calls(source, {'speed 0% is pause; %s %d': '速度 0% 等于暂停'})
|
||||
|
||||
def test_stock_tutorial_unescaped_function_key_is_a_display_span(self):
|
||||
text = '\fTo use a charged spell select it by pressing the "F" KEY marked under it'
|
||||
source = b'SetMissionDescription("\\fTo use a charged spell select it by pressing the "F" KEY marked under it");'
|
||||
self.assertEqual(extract_display_literals(source)[0].text, text)
|
||||
self.assertEqual(patch_display_calls(source, {text: '\f按 F 键'}),
|
||||
'SetMissionDescription("\\f按 F 键");'.encode('gb2312'))
|
||||
self.assertEqual(extract_display_literals(b'SetMissionDescription("other "F" text");'), [])
|
||||
|
||||
def test_only_direct_display_literals_change(self):
|
||||
source = b'DisplayMessage("Help", "Move");\r\nEnableEvent("Move");\r\n'
|
||||
result = patch_display_calls(source, {'Help': '帮助', 'Move': '移动'})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Complete tutorial mapping coverage and engine layout regression."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from test_cjk_layout import layout
|
||||
|
||||
|
||||
class CompleteTutorialTests(unittest.TestCase):
|
||||
def test_remaining_pages_fit_without_orphan_punctuation(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
for original, translated in list(mapping.items())[12:]:
|
||||
for width in (360, 400, 480):
|
||||
with self.subTest(original=original[:40], width=width):
|
||||
lines, _ = layout(translated, width=width, alignment=2)
|
||||
self.assertTrue(lines)
|
||||
self.assertLessEqual(lines[-1][2] + 12, 140)
|
||||
for raw, x, y in lines:
|
||||
text = raw.decode('gb2312')
|
||||
self.assertNotIn(text[0], ',。;:、!?)”')
|
||||
pixels = sum(5 if c == ' ' else 8 if ord(c) < 128 else 12 for c in text)
|
||||
self.assertEqual(x, width // 2 - pixels // 2)
|
||||
|
||||
def test_translations_cover_core_controls_and_constraints(self):
|
||||
mapping = json.loads(Path(__file__).with_name('tutorial_cn.json').read_text(encoding='utf-8'))
|
||||
text = '\n'.join(mapping.values())
|
||||
for term in ['PgUp', 'PgDn', 'Tab', '空格', '50%', 'E 键', 'F12', 'Alt', '0%', 'P 键', 'Esc']:
|
||||
self.assertIn(term, text)
|
||||
self.assertEqual(sum(k.startswith('\fTo use a charged spell') for k in mapping), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+31
-1
@@ -10,5 +10,35 @@
|
||||
"\fYou can zoom the camera by rotating the mouse wheel or by pressing the \"+\" and \"-\" keys on the numeric keypad.": "\f滚动鼠标滚轮,或按数字小键盘上的 +、- 键,可拉近或拉远视角。",
|
||||
"Flight": "飞行",
|
||||
"\fYou can fly using the mouse or the keyboard. Just left-click anywhere - and your dragon will fly there (note the indicator that appears there with a light globe that marks your dragon's altitude).\n\fYou can also use the keyboard to fly: up arrow moves forward, left and right arrow keys steer.": "\u0007左键点击目的地即可飞行,光球表示飞行高度。\n\u0007也可用方向键:上键前进,左、右键转向。",
|
||||
"\fYou can fly using the mouse or the keyboard. Just left-click anywhere - and your dragon will fly there (note the indicator that appears there with a light globe that marks your dragon's altitude).\n\fYou can also use the keyboard to fly: up arrow moves forward, left and right arrow keys steer.": "\u0007左键点击目的地即可飞行,光球表示飞行高度。\n\u0007也可用方向键:上键前进,左、右键转向。"
|
||||
"\fYou can fly using the mouse or the keyboard. Just left-click anywhere - and your dragon will fly there (note the indicator that appears there with a light globe that marks your dragon's altitude).\n\fYou can also use the keyboard to fly: up arrow moves forward, left and right arrow keys steer.": "\u0007左键点击目的地即可飞行,光球表示飞行高度。\n\u0007也可用方向键:上键前进,左、右键转向。",
|
||||
"Flight height": "飞行高度",
|
||||
"\fIf you fly too low, enemies will hit you easily and if you fly too high - you'll have problems aiming accurately. Besides that, you can easily avoid enemy fire by changing altitude. Use \"Q\" and \"A\" keys or \"PgUp\" and \"PgDn\" or the buttons in the lower left hand corner of the screen.\n\u0007(Arrow marks the height change buttons)": "\u0007低飞容易受攻击,高飞难以瞄准;变换高度可躲避攻击。用 Q、A 或 PgUp、PgDn 键调节高度,也可点击左下角按钮。\n\u0007箭头指向高度调节按钮。",
|
||||
"Attack": "攻击",
|
||||
"\fThe easiest way to attack something is to right-click on it. If you use the keyboard to fly - just turn to the target and it will be highlighted. Then press \"Tab\" or \"Space\" to shoot.": "\u0007右键点击目标即可攻击。用方向键飞行时,转向目标使其高亮,再按 Tab 或空格键攻击。",
|
||||
"Charged attack": "蓄力攻击",
|
||||
"\fYou can make your next attack much more powerful by charging it before shooting. Press and hold the attack button until the charge gauge in the top left hand corner fills. Attacking power is directly proportional to the gauge value. To shoot - just release the attack button. Try to charge your attack now to at least 50%.\n\u0007(Arrow marks the attack charge gauge)": "\u0007按住攻击键蓄力,左上角的蓄力条越满,攻击越强。松开攻击键即可发射。现在请蓄力至少 50%。\n\u0007箭头指向攻击蓄力条。",
|
||||
"\fTry to charge your attack now to at least 50% by holding the attack button.": "\u0007按住攻击键,将蓄力条充至至少 50%。",
|
||||
"Different attacks": "切换攻击",
|
||||
"\fYou can select different natural attacks such as breath by using key \"2\" on the keyboard or clicking the screen button.\n\u0007(Arrow marks the breath attack button)": "\u0007按数字键 2 或点击对应按钮,可切换为吐息攻击。\n\u0007箭头指向吐息按钮。",
|
||||
"\fSelect the breath attack": "\u0007请选择吐息攻击。",
|
||||
"Breath usage details": "吐息用法",
|
||||
"\fNote that if you press the attack button without holding it while using breath attack, the dragon will gather a full charge first. You can hold the button as usual if you want to use a half-charge, for example.": "\u0007轻按攻击键后松开,吐息会自动蓄满力再发射。要用半蓄力吐息,就按住攻击键,蓄力一半时松开。",
|
||||
"Eating": "进食",
|
||||
"\fDragons need to eat sometimes. You can catch small animals and monsters, if your stamina indicator (green) is full - it fills by itself over time. Select the \"catch\" attack by pressing the \"E\" button or clicking the screen button and attacking as usual. To devour your prey, press the attack button or \"E\" once more. \n\fA hungry dragon (when the yellow indicator is empty) regenerates its health, breath and mana much slower.\n\u0007(Arrows mark the catch attack button and hunger/stamina indicator)": "\u0007绿色体力条会随时间恢复,满时可抓取小动物或怪物。按 E 键或点击抓取按钮,再像平常一样攻击猎物。抓住后,再按攻击键或 E 键吞食。\n\u0007黄色饥饿条耗尽时,生命、吐息和魔力的恢复速度会大幅降低。\n\u0007箭头指向抓取按钮及饥饿、体力条。",
|
||||
"Charging spells": "法术充能",
|
||||
"\fDragons are magical creatures. Sometimes you get a new spell. It appears along the right edge of the screen as a grayed out, round icon.\n\fTo use a spell, move it into one of the spell slots along the top edge of the screen either by dragging it, or just by clicking on an icon. The spell will start to charge. As soon as it's completely surrounded by a blue line you can use it.\n\u0007(Arrows mark spell slots)": "\u0007获得的新法术会以灰色圆形图标显示在屏幕右侧。\n\u0007点击图标,或将其拖到屏幕上方的法术槽中,即可开始充能。蓝色边框完整围住图标后,法术便可使用。\n\u0007箭头指向法术槽。",
|
||||
"Casting spells": "施放法术",
|
||||
"\fTo use a charged spell select it by left-clicking on the icon or pressing the \"F\" KEY marked under it (in this case it's \"F12\") and then attack in the usual way (right mouse button or \"Tab\" or \"Space\" key).\n\fIf you plan to attack with magic for a while, you can select a few spells by holding \"ALT\" and left-clicking on them and they will be used automatically as soon as they are charged (spells with lower numbers are selected first if more than one is currently charged).": "\u0007点击已充能的法术,或按图标下的功能键选择它(本次为 F12),再用鼠标右键、Tab 或空格键攻击。\n\u0007按住 Alt 并左键点击多个法术,可让它们充能后自动选用。同时充满时,优先选用编号较小的法术。",
|
||||
"\fShoot with a spell.": "\u0007请用法术攻击一次。",
|
||||
"Inactive spells": "备用法术",
|
||||
"\fIf you use a lot of spells, some day you'll have too many of them to fit into your spell slots. To remove them from the screen (temporarily, of course) you can left-click the screen button in the top right hand corner. Click it again to show the spells list again.\n\u0007(Arrow marks the button that toggles the inactive spells list)": "\u0007法术槽放不下所有法术时,可点击右上角按钮,暂时隐藏备用法术列表。再次点击即可显示列表。\n\u0007箭头指向备用法术列表的开关。",
|
||||
"\fPress this button again to show the spells list.": "\u0007请再点一次此按钮,显示法术列表。",
|
||||
"Speed control": "游戏速度",
|
||||
"\fYou can always make the game faster or slower to suit your taste (use keys \"+\" and \"-\" on the main keyboard.) To pause the game (press the \"P\" key). You can look around or give commands to your dragon at any time, while the game is paused.\n\u0007(Don't set the pause mode now and remember that speed 0% is, effectively, also pause.)\n\u0007(Arrow marks the speed-related controls)": "\u0007主键盘 +、- 键调整速度,P 键暂停。暂停时可自由观察并下达指令。\n\u0007这一步请勿暂停;速度调至 0% 也等同暂停。\n\u0007箭头指向游戏速度控件。",
|
||||
"\fChange the game speed (don't set the pause now, please).": "\u0007请改变游戏速度,这一步不要暂停。",
|
||||
"End of tutorial": "教程结束",
|
||||
"\u0007Congratulations! Now you are ready to play the game! \n\u0007To exit the tutorial press the \"Esc\" button.": "\u0007恭喜完成教程!现在可以开始游戏了!\n\u0007按 Esc 键退出教程。",
|
||||
"\fIf you fly too low, enemies will hit you easily and if you fly too high - you'll have problems aiming accurately. Besides that, you can easily avoid enemy fire by changing altitude. Use hand \"Q\" and \"A\" keys or \"PgUp\" and \"PgDn\" or the buttons in the lower left hand corner of the screen.\n\u0007(Arrow marks the height change buttons)": "\u0007低飞容易受攻击,高飞难以瞄准;变换高度可躲避攻击。用 Q、A 或 PgUp、PgDn 键调节高度,也可点击左下角按钮。\n\u0007箭头指向高度调节按钮。",
|
||||
"\fDragons need to eat sometimes. You can catch small animals and monsters if your stamina indicator (green) is full - it fills by itself over time. Select the \"catch\" attack by pressing the \"E\" button or clicking the screen button and attacking as usual. To devour your prey, press the attack button or \"E\" once more. \n\fA hungry dragon (when the yellow indicator is empty) regenerates its health, breath and mana much slower.\n\u0007(Arrows mark the catch attack button and hunger/stamina indicator)": "\u0007绿色体力条会随时间恢复,满时可抓取小动物或怪物。按 E 键或点击抓取按钮,再像平常一样攻击猎物。抓住后,再按攻击键或 E 键吞食。\n\u0007黄色饥饿条耗尽时,生命、吐息和魔力的恢复速度会大幅降低。\n\u0007箭头指向抓取按钮及饥饿、体力条。",
|
||||
"\fYou can always make the game faster or slower to suit your taste (use keys \"+\" and \"-\" on the main keyboard for that) or pause the game (press the \"P\" key). You can look around or give commands to your dragon at any time, while the game is paused.\n\u0007(Don't set the pause mode now and remember that speed 0% is, effectively, also pause.)\n\u0007(Arrow marks the speed-related controls)": "\u0007主键盘 +、- 键调整速度,P 键暂停。暂停时可自由观察并下达指令。\n\u0007这一步请勿暂停;速度调至 0% 也等同暂停。\n\u0007箭头指向游戏速度控件。"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Verify the bounded first five tutorial groups against an original backup."""
|
||||
"""Verify the bounded complete tutorial translation against an original backup."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
@@ -25,7 +25,7 @@ def main():
|
||||
assert result == patch_display_calls(source, mapping, allow_alignment_changes=True), 'build mismatch'
|
||||
before = extract_display_literals(source)
|
||||
after = extract_display_literals(result, encoding='gb2312')
|
||||
assert len(before) == len(after) == 55
|
||||
assert len(before) == len(after) == 57
|
||||
changed = []
|
||||
left = right = 0
|
||||
for i, (a, b) in enumerate(zip(before, after)):
|
||||
@@ -38,8 +38,8 @@ def main():
|
||||
assert source[a.start:a.end] == result[b.start:b.end]
|
||||
left, right = a.end, b.end
|
||||
assert source[left:] == result[right:], 'trailing bytes changed'
|
||||
assert changed == list(range(15)), changed
|
||||
print('MODIFIED: first 15 of 55 literals translated; all other bytes unchanged; PASS')
|
||||
assert changed == list(range(57)), changed
|
||||
print('MODIFIED: all 57 display literals translated; all other bytes unchanged; PASS')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user