File size: 15,954 Bytes
5afb7b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
"""Bilingual UI strings table for The Mentor's Oracles.

Centralizes every player-facing UI label, button, info text, badge,
heading, error message and template paragraph so the UI can flip between
English and Simplified Chinese based on `GameState.lang`.

Dynamic narrative output from the LLM (obstacle setup, narration, tactic,
interlude, epilogue) is NOT routed through this table — those flow through
the `{language}` placeholder in the prompt templates instead. Same with
the mentor's grimoire soliloquy, which is loaded per-language from
``oracles/mentor_text.py``.

Public API:
  * ``UI_STRINGS``: dict[key -> dict[lang -> str]]
  * ``t(key, lang="en")``: localized lookup, English fallback
  * ``tlang(state)``: read ``state.lang`` safely, default "en"

Keys use ``msg_…`` for transient processing strings, ``btn_…`` for
button labels, ``label_…`` for component labels, ``info_…`` for
component help text, ``ph_…`` for placeholders, ``badge_…`` for status
chips, ``heading_…`` for headings, ``err_…`` for validation errors,
``tmpl_…`` for template paragraphs.

Placeholders inside the strings (e.g. ``{hero}``, ``{step}``,
``{GRIMOIRE_NUM_STEPS}``) are filled by the caller via ``str.format`` —
do NOT pre-format here.
"""

from __future__ import annotations

from typing import Any


UI_STRINGS: dict[str, dict[str, str]] = {
    # ---- Grimoire / inscribe phase ----------------------------------------
    "grimoire_step_marker": {
        "en": "{step:02d} / {GRIMOIRE_NUM_STEPS:02d}",
        "zh": "第 {step:02d} / {GRIMOIRE_NUM_STEPS:02d} 页",
    },
    "blank_oracle": {
        "en": "[blank]",
        "zh": "[空白]",
    },
    "alt_sealed_grimoire": {
        "en": "the sealed grimoire",
        "zh": "已封缄的卷书",
    },
    "summary_five_sealed": {
        "en": "The five sealed oracles:",
        "zh": "已封缄的五道神谕:",
    },

    # ---- Prologue ----------------------------------------------------------
    "prologue_title": {
        "en": "~~ THE PROLOGUE ~~",
        "zh": "~~ 序章 ~~",
    },

    # ---- Pipeline-demo card -----------------------------------------------
    "demo_mode": {
        "en": "Mode {mode}",
        "zh": "第 {mode} 式",
    },
    "demo_tag_inscribes": {
        "en": "\U0001f4dc He inscribes",
        "zh": "\U0001f4dc 他写下",
    },
    "demo_tag_meets": {
        "en": "⚔️ The apprentice meets",
        "zh": "⚔️ 弟子遭遇",
    },
    "demo_tag_somehow": {
        "en": "✨ Somehow",
        "zh": "✨ 不知怎的",
    },
    "demo_header": {
        "en": "WHAT THE MENTOR IMAGINES MIGHT HAPPEN…",
        "zh": "导师设想可能会发生的事……",
    },
    "demo_mode_a_label": {
        "en": "Wild Imagination",
        "zh": "天马行空",
    },
    "demo_mode_b_label": {
        "en": "Accidental Trip",
        "zh": "阴差阳错",
    },
    "demo_mode_c_label": {
        "en": "Last-Minute Revelation",
        "zh": "灵光乍现",
    },
    "demo_footer": {
        "en": (
            "Now <strong>YOU</strong> are the mentor. Inscribe any 5 words "
            "you wish. The story-engine will pretend they save the apprentice."
        ),
        "zh": (
            "如今 <strong>你</strong> 就是那位"
            "导师。随意写下五个字"
            "句。故事引擎会让它们"
            "成为弟子的救命之言。"
        ),
    },

    # ---- Inscribe decoration (the YOU / YOUR APPRENTICE figs) ----------------
    "figure_you": {
        "en": "YOU",
        "zh": "你",
    },
    "figure_apprentice": {
        "en": "YOUR APPRENTICE",
        "zh": "你的弟子",
    },

    # ---- Banner fallback subtitle ----------------------------------------
    "banner_default_subtitle": {
        "en": "You inscribe the words. The apprentice must make them save his life.",
        "zh": "字句由你写下。弟子须"
              "以这些字句保住性命。",
    },

    # ---- Badge / status chip ----------------------------------------------
    "badge_inscribing": {
        "en": "INSCRIBING",
        "zh": "书写中",
    },
    "badge_sealed": {
        "en": "SEALED",
        "zh": "已封缄",
    },
    "badge_trial": {
        "en": "TRIAL {current} / {total}",
        "zh": "第 {current} / {total} 难",
    },
    "badge_return": {
        "en": "RETURN",
        "zh": "归途",
    },
    "badge_done": {
        "en": "DONE",
        "zh": "已毕",
    },

    # ---- Send-off / sealed-list panel -------------------------------------
    "blank_silence": {
        "en": "[silence]",
        "zh": "[沉默]",
    },
    "sealed_list_header": {
        "en": "The five oracles, sealed:",
        "zh": "五道神谕,已封缄:",
    },
    "tmpl_send_off_narration": {
        "en": (
            "{hero} kneels by the well at dawn. You — the anonymous "
            "mentor — place the five sealed oracles in his pack and "
            "step back into the mist. He does not see your face. He rises, "
            "takes the road south, and the village of {village} falls "
            "behind him."
        ),
        "zh": (
            "{hero} 在黎明时分跪于井"
            "边。你——那位无名的"
            "导师——将五道封缄的"
            "神谕放入他的行囊,退"
            "入薄雾之中。他未曾见"
            "过你的面容。他起身南"
            "行,{village} 在他身后渐行"
            "渐远。"
        ),
    },

    # ---- Trial heading + await --------------------------------------------
    "trial_dragon_title": {
        "en": "Trial {n}: The Dragon",
        "zh": "第 {n} 难:巨龙",
    },
    "trial_normal_title": {
        "en": "Trial {n} of {total}",
        "zh": "第 {n} 难,共 {total} 难",
    },
    "trial_remaining_one": {
        "en": (
            "He has <strong>{remaining}</strong> oracle left. "
            "The path forward is barred."
        ),
        "zh": (
            "他还剩 <strong>{remaining}</strong> 道"
            "神谕。前路已被阻断。"
        ),
    },
    "trial_remaining_many": {
        "en": (
            "He has <strong>{remaining}</strong> oracles left. "
            "The path forward is barred."
        ),
        "zh": (
            "他还剩 <strong>{remaining}</strong> 道"
            "神谕。前路已被阻断。"
        ),
    },

    # ---- Trial reveal ------------------------------------------------------
    "oracle_label_sealed_since": {
        "en": "Oracle {roman} (sealed since the village):",
        "zh": "第 {roman} 道神谕(自村庄"
              "起便已封缄):",
    },
    "parchment_blank_quote": {
        "en": "[the parchment was blank]",
        "zh": "[这张羊皮卷是空白的]",
    },
    "tactic_prefix": {
        "en": "Tactic: {tactic}",
        "zh": "策略:{tactic}",
    },

    # ---- Chronicle ---------------------------------------------------------
    "chronicle_empty": {
        "en": "_The chronicle is empty._",
        "zh": "_史册尚未落墨。_",
    },
    "chronicle_trial_heading": {
        "en": "### Trial {n}",
        "zh": "### 第 {n} 难",
    },
    "chronicle_obstacle": {
        "en": "**Obstacle:** {ob_setup}",
        "zh": "**险境:** {ob_setup}",
    },
    "chronicle_oracle": {
        "en": "**Oracle {roman}:** _{oracle_text}_",
        "zh": "**第 {roman} 道神谕:** _{oracle_text}_",
    },
    "chronicle_tactic": {
        "en": "**Tactic:** {tactic}",
        "zh": "**策略:** {tactic}",
    },

    # ---- Epilogue ----------------------------------------------------------
    "epilogue_heading_return": {
        "en": "Return",
        "zh": "归途",
    },

    # ---- Validation errors -------------------------------------------------
    "err_hero_blank": {
        "en": "The parchment is blank. The apprentice needs a name.",
        "zh": "羊皮卷尚是一片空白。"
              "这位弟子需要一个名字。",
    },
    "err_village_blank": {
        "en": "Every apprentice comes from somewhere. Name the place.",
        "zh": "每位弟子皆有所来之处。"
              "为那地方起个名吧。",
    },
    "err_parchment_blank": {
        "en": "The {ord_word} parchment cannot be blank. Inscribe at least one mark.",
        "zh": "第{ord_word}卷羊皮不可空白。"
              "至少落下一笔。",
    },

    # English ordinal words used inside err_parchment_blank's English form.
    # The Chinese version uses the numeric character so we map each to its
    # localized equivalent here.
    "ord_first": {"en": "first", "zh": "一"},
    "ord_second": {"en": "second", "zh": "二"},
    "ord_third": {"en": "third", "zh": "三"},
    "ord_fourth": {"en": "fourth", "zh": "四"},
    "ord_fifth": {"en": "fifth", "zh": "五"},
    "ord_next": {"en": "next", "zh": "下一"},

    # ---- Processing-overlay captions ---------------------------------------
    "msg_walking_east": {
        "en": "The apprentice walks east along the dawn road…",
        "zh": "弟子沿着拂晓之路向"
              "东而行……",
    },
    "msg_reading_oracle": {
        "en": "The apprentice reads the oracle aloud…",
        "zh": "弟子朗读神谕……",
    },
    "msg_oracle_quivers": {
        "en": (
            "The oracle quivers and goes still. The mentor's voice does "
            "not arrive: {e}"
        ),
        "zh": (
            "神谕颤动,随即归于死"
            "寂。导师的话音未至:{e}"
        ),
    },
    "msg_no_tactic": {
        "en": "(no tactic — the LLM could not be reached)",
        "zh": "(无策略——无法连"
              "接到大模型)",
    },
    "msg_trial_caption_fallback": {
        "en": "Trial {n}: {tactic}",
        "zh": "第 {n} 难:{tactic}",
    },
    "msg_walking_between_trials": {
        "en": "The apprentice walks the road between trials…",
        "zh": "弟子走在两难之间的路"
              "上……",
    },
    "msg_climbing_final": {
        "en": "The apprentice climbs toward the final reckoning…",
        "zh": "弟子向那最后的清算攀"
              "登……",
    },
    "msg_road_silent": {
        "en": "[the road between trials is silent — {e}]",
        "zh": "[两难之间的路途寂然无"
              "声——{e}]",
    },
    "msg_mentor_silent": {
        "en": "[the mentor is silent — {e}]",
        "zh": "[导师无言——{e}]",
    },

    # ---- Grimoire spread-0 dropdowns / info text --------------------------
    # NOTE: the picker LABELS for language and theme stay bilingual
    # ("Tongue / 语言", "World / 世界") because they are seen BEFORE
    # the language has been chosen. Only the INFO body is localized here.
    "info_language_dropdown": {
        "en": "The mentor will write in this tongue for the rest of the tale.",
        "zh": "此后整个故事,导师都"
              "将以此语言书写。",
    },
    "info_theme_dropdown": {
        "en": (
            "The shape of the world the apprentice walks. Each setting has its "
            "own mentor, obstacles, and final reckoning."
        ),
        "zh": (
            "弟子所行世界之形貌。"
            "每一设定皆有其专属的"
            "导师、险境与最终之劫"
            "。"
        ),
    },
    "label_narration_length": {
        "en": "Narration length per trial",
        "zh": "每一难的叙述长度",
    },
    "info_narration_length": {
        "en": (
            "How many words you want the mentor to spin per trial. "
            "Shorter = snappier, longer = more vivid. You can rerun the "
            "journey with a different choice."
        ),
        "zh": (
            "你希望导师在每一难中"
            "编织多少字句。越短越"
            "紧凑,越长越生动。你"
            "可以选择不同长度再走"
            "一遍旅程。"
        ),
    },

    # ---- Grimoire button labels -------------------------------------------
    "btn_inscribe_choice": {
        "en": "Inscribe my choice →",
        "zh": "印下我的选择 →",
    },
    "label_hero_name": {
        "en": "The apprentice's name",
        "zh": "弟子之名",
    },
    "ph_hero_name": {
        "en": "A name a dragon would forget…",
        "zh": "一个连巨龙也会忘记的"
              "名字……",
    },
    "btn_name_him": {
        "en": "Name him →",
        "zh": "为他起名 →",
    },
    "label_village": {
        "en": "His village",
        "zh": "他的村庄",
    },
    "ph_village": {
        "en": "A place with a well and a bell…",
        "zh": "一个有水井与钟声的地"
              "方……",
    },
    "btn_place_him": {
        "en": "Place him →",
        "zh": "为他定居 →",
    },
    "label_oracle_1": {
        "en": "The first parchment",
        "zh": "第一卷羊皮",
    },
    "ph_oracle_1": {
        "en": "Anything you write may save him.",
        "zh": "你写下的任何字句皆可"
              "救他一命。",
    },
    "btn_seal_first": {
        "en": "Seal the first →",
        "zh": "封缄第一卷 →",
    },
    "label_oracle_2": {
        "en": "The second parchment",
        "zh": "第二卷羊皮",
    },
    "btn_seal_second": {
        "en": "Seal the second →",
        "zh": "封缄第二卷 →",
    },
    "label_oracle_3": {
        "en": "The third parchment",
        "zh": "第三卷羊皮",
    },
    "btn_seal_third": {
        "en": "Seal the third →",
        "zh": "封缄第三卷 →",
    },
    "label_oracle_4": {
        "en": "The fourth parchment",
        "zh": "第四卷羊皮",
    },
    "btn_seal_fourth": {
        "en": "Seal the fourth →",
        "zh": "封缄第四卷 →",
    },
    "label_oracle_5": {
        "en": "The fifth (and final) parchment",
        "zh": "第五卷(也是最后一卷)羊皮",
    },
    "ph_oracle_5": {
        "en": "He will open this at the dragon.",
        "zh": "他将在巨龙面前开启此卷。",
    },
    "btn_seal_last": {
        "en": "Seal the last →",
        "zh": "封缄末卷 →",
    },
    "btn_let_journey_begin": {
        "en": "Let the journey begin →",
        "zh": "旅程启航 →",
    },

    # ---- Trial action buttons ---------------------------------------------
    "btn_open_oracle": {
        "en": "He opens one of the mentor's oracles.",
        "zh": "他启封导师的一道神谕。",
    },
    "btn_continue": {
        "en": "Continue.",
        "zh": "继续。",
    },

    # ---- Epilogue chrome --------------------------------------------------
    "label_chronicle_accordion": {
        "en": "Chronicle",
        "zh": "史册",
    },
    "btn_new_tale": {
        "en": "Begin a new tale",
        "zh": "开启新的传说",
    },
    "btn_to_chronicle": {
        "en": "Continue to the chronicle",
        "zh": "翻阅史册",
    },
}


def t(key: str, lang: str = "en") -> str:
    """Return the localized string for `key` in `lang`.

    Falls back to the English string when `lang` has no entry. If the
    key itself is unknown, returns the key (so callers see a clear miss
    instead of an empty string).
    """
    entry = UI_STRINGS.get(key)
    if entry is None:
        return key
    val = entry.get(lang)
    if val is not None:
        return val
    return entry.get("en", key)


def tlang(state: Any) -> str:
    """Read the language code from a GameState (or "en" if no state).

    Defensive: handles ``None`` state and a state object missing ``lang``
    so call sites in render helpers don't have to guard.
    """
    if state is None:
        return "en"
    lang = getattr(state, "lang", None) or "en"
    return lang if lang in ("en", "zh") else "en"