-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathi18n.py
More file actions
380 lines (347 loc) · 18 KB
/
Copy pathi18n.py
File metadata and controls
380 lines (347 loc) · 18 KB
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
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 alvations (Melon Lab)
"""Localization core: the string-key scheme, the locale registry, and the
compiled-catalog loader shared by the runtime (hallway.py, app.py) and the
tooling (scripts/i18n_*.py).
Design
------
Every translatable string in an arc has a deterministic key derived from its
position in the arc JSON (see ``arc_items``). The English text in the arc files
is the source of truth. Translations live in an audit file
(``data/i18n/lines.json``, keyed by string-key, holding every locale with its
text/score/postedit/postedit_score) and are *compiled* down to a flat runtime
catalog per locale (``data/i18n/compiled/<locale>.json`` = {key: final_text}).
At runtime, hallway.py rebuilds the same key for each line it picks and looks up
the localized text, falling back to the English original when a key is missing.
So partial translations degrade gracefully to English, string by string.
"""
from __future__ import annotations
import json
import os
from typing import Dict, Iterator, List, Tuple
HERE = os.path.dirname(os.path.abspath(__file__))
I18N_DIR = os.path.join(HERE, "data", "i18n")
COMPILED_DIR = os.path.join(I18N_DIR, "compiled")
SOURCE_LOCALE = "en_US"
# Supported locales: code -> display metadata. `native` is what the picker shows.
# All of these are left-to-right.
LOCALES: List[dict] = [
{"code": "en_US", "name": "English", "native": "English"},
{"code": "en_GB", "name": "English (UK)", "native": "English (UK)"},
{"code": "de_DE", "name": "German", "native": "Deutsch"},
{"code": "es_ES", "name": "Spanish", "native": "Español"},
{"code": "fr_FR", "name": "French", "native": "Français"},
{"code": "fr_CA", "name": "French (Canada)", "native": "Français (Canada)"},
{"code": "it_IT", "name": "Italian", "native": "Italiano"},
{"code": "pt_PT", "name": "Portuguese", "native": "Português"},
{"code": "nl_NL", "name": "Dutch", "native": "Nederlands"},
{"code": "cs_CZ", "name": "Czech", "native": "Čeština"},
{"code": "ja_JP", "name": "Japanese", "native": "日本語"},
{"code": "ko_KR", "name": "Korean", "native": "한국어"},
{"code": "zh_CN", "name": "Chinese (Simplified)", "native": "简体中文"},
{"code": "zh_TW", "name": "Chinese (Taiwan)", "native": "繁體中文(台灣)"},
{"code": "yue_HK", "name": "Cantonese", "native": "廣東話"},
{"code": "vi_VN", "name": "Vietnamese", "native": "Tiếng Việt"},
{"code": "th_TH", "name": "Thai", "native": "ไทย"},
{"code": "id_ID", "name": "Indonesian", "native": "Bahasa Indonesia"},
{"code": "ms_MY", "name": "Malay", "native": "Bahasa Melayu"},
{"code": "hi_IN", "name": "Hindi", "native": "हिन्दी"},
{"code": "ta_IN", "name": "Tamil", "native": "தமிழ்"},
]
LOCALE_CODES = [l["code"] for l in LOCALES]
# Locales exposed in the language picker for this phase. Translations for other
# locales may already be complete and compiled, but they stay hidden until we
# deliberately promote them here. Keep this list authoritative for what ships.
EXPOSED_LOCALES = [
"en_US", "de_DE", "fr_FR", "pt_PT", "ja_JP", "ko_KR", "zh_CN", "vi_VN",
]
def is_locale(code: str) -> bool:
return code in LOCALE_CODES
# Short codes for the "I18N-XX" unlock promo -> full locale. Two letters each so
# the code "I18N-XX" stays within the promo box.
PROMO_LOCALE = {
"EN": "en_US", "GB": "en_GB", "DE": "de_DE", "ES": "es_ES", "FR": "fr_FR",
"CA": "fr_CA", "IT": "it_IT", "PT": "pt_PT", "NL": "nl_NL", "CS": "cs_CZ",
"JA": "ja_JP", "KO": "ko_KR", "ZH": "zh_CN", "TW": "zh_TW", "HK": "yue_HK",
"VI": "vi_VN", "TH": "th_TH", "ID": "id_ID", "MS": "ms_MY", "HI": "hi_IN",
"TA": "ta_IN",
}
# --- the string-key scheme -------------------------------------------------
def arc_items(arc_id: str, data: dict) -> Iterator[Tuple[str, str]]:
"""Yield (key, english_text) for every translatable string in an arc, in a
stable order. The same keys are recomputed at runtime and by the tooling."""
meta = data.get("meta", {})
yield (f"a:{arc_id}|title_main", meta.get("title", ""))
yield (f"a:{arc_id}|tagline", meta.get("tagline", ""))
yield (f"a:{arc_id}|go_on", meta.get("go_on", ""))
yield (f"a:{arc_id}|turn_back", meta.get("turn_back", ""))
yield (f"a:{arc_id}|progress", meta.get("progress", ""))
yield (f"a:{arc_id}|share_prompt", meta.get("share_prompt", ""))
yield (f"a:{arc_id}|attempt", meta.get("attempt", ""))
yield (f"a:{arc_id}|attempt_first", meta.get("attempt_first", ""))
for prop, lab in meta.get("labels", {}).items():
yield (f"a:{arc_id}|label|{prop}", lab)
for i, t in enumerate(data.get("title_variants", [])):
yield (f"a:{arc_id}|title|{i}", t)
for prop, spec in data.get("properties", {}).items():
for val, pool in spec.get("values", {}).items():
for i, t in enumerate(pool):
yield (f"a:{arc_id}|p:{prop}|v:{val}|{i}", t)
for val, pool in spec.get("anomalies", {}).items():
for i, t in enumerate(pool):
yield (f"a:{arc_id}|p:{prop}|x:{val}|{i}", t)
for kind, pool in data.get("adaptive", {}).items():
for i, t in enumerate(pool):
yield (f"a:{arc_id}|adaptive|{kind}|{i}", t)
for kind, pool in data.get("framing", {}).items():
for i, t in enumerate(pool):
yield (f"a:{arc_id}|framing|{kind}|{i}", t)
# Act 2: the optional "touch the room" interactions (see docs/ACT2.md).
act2 = data.get("act2", {})
for i, t in enumerate(act2.get("flashbacks", [])):
yield (f"a:{arc_id}|act2|flashback|{i}", t)
ex = act2.get("exit", {})
if ex.get("tempt"):
yield (f"a:{arc_id}|act2|exit|tempt", ex.get("tempt", ""))
if ex.get("action"):
yield (f"a:{arc_id}|act2|exit|action", ex.get("action", ""))
if ex.get("utterance"):
yield (f"a:{arc_id}|act2|exit|utterance", ex.get("utterance", ""))
for eid, pool in act2.get("endings", {}).items():
for i, t in enumerate(pool):
yield (f"a:{arc_id}|act2|end|{eid}|{i}", t)
for prop, spec in act2.get("touch", {}).items():
yield (f"a:{arc_id}|act2|t:{prop}|verb", spec.get("verb", ""))
for kind in ("reaction", "herring", "fold"):
for i, t in enumerate(spec.get(kind, [])):
yield (f"a:{arc_id}|act2|t:{prop}|{kind}|{i}", t)
# UI strings that live outside the arcs (source English). Keys are `ui|<name>`.
UI_STRINGS_EN: Dict[str, str] = {
"select_lead_1": "Somewhere you can't quite leave.",
"select_lead_2": "Choose a way through.",
"begin": "Begin",
"back": "Back",
"again": "Walk it again",
"change": "Change scenario",
"give_up": "Give up · back to start",
"conf_q": "How certain are you?",
"conf_1": "Guessing",
"conf_2": "I think so",
"conf_3": "Almost sure",
"conf_4": "Certain",
"language": "Language",
# Heading for the first-reset onboarding panel. A neutral "Instructions"
# label, so the arc's scene title (e.g. a time-of-day framing) never reads
# as part of the how-to. The panel body reuses the arc's own backstory.
"instructions": "Instructions",
# The fleeting "look back" toggle on the win screen. Metaphoric (reflect on
# the run just finished), NOT a physical "turn around": translators must use
# the retrospective sense in each language.
"look_back": "Look back.",
# Shown when a touch folds the run back to the start, so the player learns it
# was their own action, not a glitch. The opt-out appears only from the 2nd
# time (see game.js maybeFoldExplainer).
"fold_title": "Back to the start.",
"fold_1": "That was you. You reached for something in the place, and it folded the way back on you.",
"fold_2": "Touching what you find is never necessary. Look if you like, but a wrong touch can send you here.",
"fold_hide": "Don't show this again.",
# The display / accessibility settings panel (gear). Defaults leave the look
# exactly as it is; these only change things if the player opts in.
"settings_title": "Display",
"set_text": "Text size",
"set_bright": "Brightness",
"set_motion": "Reduce motion",
"set_done": "Done",
# Achievements / collection page.
"ach_open": "Achievements",
"ach_save": "Save",
"ach_load": "Load",
# Erase: wipe the local memory back to a blank slate (with a warning gate).
"erase": "Erase",
"erase_title": "Erase your memory?",
"erase_body": "Everything this place remembers of you goes: the fragments you have recollected, the marks you have earned, every run. This cannot be undone.",
"erase_cancel": "Keep it",
"ach_locked": "Locked",
"ach_flash": "Flashbacks",
"ach_progress": "{n} of {total} unlocked",
"promo_hint": "Code",
"promo_go": "Redeem",
"promo_ok": "Unlocked.",
"promo_bad": "That code does nothing here.",
"promo_have": "Already redeemed.",
# The memory overlay: its header, its empty state, and the fragment section.
"mem_title": "Memory",
"ach_empty": "Nothing yet. Escape a place to begin.",
"ach_section": "Achievements",
"col_section": "Recollections",
# Recalling a collected fragment from the ledger.
"col_recall": "Recall this fragment",
"col_recall_empty": "A fragment you have seen, its words already fading.",
# Re-view the ending credits from the Memory overlay.
"view_credits": "View credits",
# Win-screen button that plays the ending credits (first escape of an arc).
"end_credits": "End Credits",
# Win-screen adaptive nudge toward what is left to find ({n} = unseen count).
"nudge_frags": "There are {n} memories here you have not seen yet.",
"nudge_arcs": "Two other places are also called 8.",
"nudge_eight": "Eight escapes from one place is a different kind of remembering.",
# Records: a quiet ledger of *how* runs were won (self-competition, unscored).
"rec_section": "Records",
"rec_steady": "Escaped without a single guess.",
"rec_cold": "Escaped without a second look.",
"rec_nerve": "Turned back at the final door, and was right.",
"rec_streak": "Clean escapes in a row: {n}",
# The fleeting "how you did" line on the win screen.
"read_steady": "You never once called it a guess.",
"read_cold": "You walked it without a second look.",
"read_nerve": "You turned back at the very last door, and you were right.",
# Difficulty + reading-register settings (Display panel).
"set_diff": "Difficulty",
"set_age": "Reading",
"diff_easy": "Easy",
"diff_normal": "Normal",
"diff_hard": "Hard",
"diff_insane": "Insane",
"diff_hint_hard": "Hard adds new kinds of change.",
"diff_hint_locked": "Escape once to unlock Hard.",
"diff_hint_insane": "Insane remakes what normal is, every run.",
"age_simple": "Simple",
"age_normal": "Normal",
# The HUD "furthest reached" marker ({n} = best level).
"furthest": "furthest {n}",
# Shown when the player admits doubt and is sent back for one more look.
"hesitate_1": "You hesitate. Look again.",
"hesitate_2": "You're not sure. You take another look.",
"hesitate_3": "Doubt stops you. Read it once more.",
"hesitate_4": "You hold back, and let your eyes travel over it again.",
# The alternate-ending screen's return button.
"ending_back": "Back to the start",
# Accessibility labels for icon-only controls (screen readers).
"a11y_settings": "Display settings",
"a11y_close": "Close",
"a11y_sound_on": "Sound on",
"a11y_sound_off": "Sound off",
# Simple (younger-reader) register: a plain, spoiler-free statement of the
# rule, shown in the onboarding when Reading is set to Simple. Its QE has an
# extra "explain like I am young" simplicity gate (see docs/LOCALIZATION.md).
"rules_plain": "Something changed? Go back. Nothing changed? Go on. Eight in a row to get out.",
# Badge names (ach_n_*) and one-line descriptions (ach_d_*), discovered by
# playing; no spoilers of the specific detail to watch.
"ach_n_out_hallway-eight": "Out of Hallway 8",
"ach_d_out_hallway-eight": "Escape the corridor.",
"ach_n_out_stairway8": "Out of Stairway 8",
"ach_d_out_stairway8": "Reach the ground floor.",
"ach_n_out_coach8": "Out of Coach 8",
"ach_d_out_coach8": "Step off the train.",
"ach_n_out_all": "All the way out",
"ach_d_out_all": "Escape all three places.",
"ach_n_flawless": "Flawless",
"ach_d_flawless": "Escape a place without a single reset.",
"ach_n_eight_times": "Eight times through",
"ach_d_eight_times": "Escape one place eight times.",
"ach_n_end_hallway-eight": "The lobby that won't open",
"ach_d_end_hallway-eight": "Find Hallway 8's other ending.",
"ach_n_end_stairway8": "The stairs that only climb",
"ach_d_end_stairway8": "Find Stairway 8's other ending.",
"ach_n_end_coach8": "The platform that isn't yours",
"ach_d_end_coach8": "Find Coach 8's other ending.",
"ach_n_npc_knows": "It knows you",
"ach_d_npc_knows": "Be acknowledged by the one who is always ahead.",
"ach_n_shared": "Passed it on",
"ach_d_shared": "Send the place to someone else.",
"ach_n_opening_night": "Opening night",
"ach_d_opening_night": "Play on the day it opened.",
"ach_n_flash_hallway-eight": "Hallway, remembered",
"ach_d_flash_hallway-eight": "See every fragment in Hallway 8.",
"ach_n_flash_stairway8": "Stairway, remembered",
"ach_d_flash_stairway8": "See every fragment in Stairway 8.",
"ach_n_flash_coach8": "Coach, remembered",
"ach_d_flash_coach8": "See every fragment in Coach 8.",
"ach_n_total_recall": "Total recall",
"ach_d_total_recall": "See every last fragment, everywhere.",
"ach_n_steady": "Steady",
"ach_d_steady": "Escape without a single guess.",
"ach_n_cold": "Cold read",
"ach_d_cold": "Escape without a second look.",
"ach_n_nerve": "Nerve",
"ach_d_nerve": "Turn back at the final door, and be right.",
"ach_n_streak": "On a roll",
"ach_d_streak": "Three clean escapes in a row.",
"ach_n_hard_hallway-eight": "Hallway, harder",
"ach_d_hard_hallway-eight": "Escape Hallway 8 on hard.",
"ach_n_hard_stairway8": "Stairway, harder",
"ach_d_hard_stairway8": "Escape Stairway 8 on hard.",
"ach_n_hard_coach8": "Coach, harder",
"ach_d_hard_coach8": "Escape Coach 8 on hard.",
"ach_n_hard_all": "Nowhere left soft",
"ach_d_hard_all": "Escape all three on hard.",
"ach_n_insane_hallway-eight": "Hallway, unmade",
"ach_d_insane_hallway-eight": "Escape Hallway 8 on insane.",
"ach_n_insane_stairway8": "Stairway, unmade",
"ach_d_insane_stairway8": "Escape Stairway 8 on insane.",
"ach_n_insane_coach8": "Coach, unmade",
"ach_d_insane_coach8": "Escape Coach 8 on insane.",
"ach_n_insane_all": "Trust nothing",
"ach_d_insane_all": "Escape all three on insane.",
"ach_n_melon": "Melon Supporter",
"ach_d_melon": "Thank you for keeping the lights on. From all of us at Melon Labs.",
# Win-screen sharing. One of share_1..8 is chosen at random when the player
# gets out, in the "invitation" voice: cryptic, elegant, a challenge held
# out to a friend. The play link is appended by the client. (The prompt line
# above them is a per-arc string, keyed a:<arc>|share_prompt, not here.)
"share_native": "Share",
"share_copy": "Copy link",
"share_copied": "Link copied",
"share_1": "There is a place you can't quite leave. I left. Now it's your turn.",
"share_2": "Somewhere you can't quite leave, someone found the door. Come find yours.",
"share_3": "Eight chances to doubt yourself. I trusted the right ones. Now you.",
"share_4": "I know the way out now. I won't tell you where. Come and see.",
"share_5": "A quiet place, eight small doubts, one way through. I found it. Can you?",
"share_6": "I walked it eight times and trusted what I remembered. See if you can.",
"share_7": "The way out comes down to what you remember. I remembered. Now you try.",
"share_8": "Eight doors stood between me and the cold air. I opened every one. Your turn.",
}
def ui_items() -> Iterator[Tuple[str, str]]:
for name, text in UI_STRINGS_EN.items():
yield (f"ui|{name}", text)
# --- compiled catalog loader (runtime) -------------------------------------
_CACHE: Dict[str, Dict[str, str]] = {}
def load_compiled(locale: str) -> Dict[str, str]:
"""Load (and cache) the compiled {key: text} catalog for a locale.
Missing files yield an empty catalog, so everything falls back to English."""
if locale in _CACHE:
return _CACHE[locale]
path = os.path.join(COMPILED_DIR, f"{locale}.json")
data: Dict[str, str] = {}
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = {}
_CACHE[locale] = data
return data
def clear_cache() -> None:
_CACHE.clear()
def available_locales() -> List[dict]:
"""Locales shown in the picker: the explicit EXPOSED_LOCALES allowlist,
intersected with locales that actually have a compiled catalog (or are the
source). This keeps the picker to the phase's chosen set even when other
locales are fully translated and compiled on disk."""
return [l for l in LOCALES if l["code"] in EXPOSED_LOCALES
and (l["code"] == SOURCE_LOCALE or load_compiled(l["code"]))]
def unlock_map() -> Dict[str, dict]:
"""SHORT code -> {code, native} for the I18N-XX unlock promo, limited to
locales that actually have a compiled catalog (or the source)."""
by_code = {l["code"]: l for l in LOCALES}
out: Dict[str, dict] = {}
for short, code in PROMO_LOCALE.items():
l = by_code.get(code)
if l and (code == SOURCE_LOCALE or load_compiled(code)):
out[short] = {"code": code, "native": l["native"]}
return out
def localize(locale: str, key: str, fallback: str) -> str:
"""Return the localized string for a key, or the English fallback."""
if not locale or locale == SOURCE_LOCALE:
return fallback
return load_compiled(locale).get(key) or fallback