Implement i18n audit/localization cleanup and sync libstorm submodule

This commit is contained in:
Storm Dragon
2026-02-24 23:14:40 -05:00
parent b77b895685
commit c5d26d5edd
68 changed files with 9169 additions and 853 deletions

View File

@@ -0,0 +1,660 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[1]
TARGET_FUNCTION_ARGS = {
"speak_with_history": [0],
"screen_reader_speak": [0],
"notify": [0],
"learn_sounds_speak": [0],
"learn_sounds_add_description": [1],
"notifications_speak": [0],
"ui_info_box": [0, 1, 2],
"ui_question": [0, 1],
"ui_input_box": [0, 1],
"virtual_info_box": [0, 1, 2],
"virtual_question": [0, 1],
"virtual_input_box": [0, 1],
"text_reader": [1],
"text_reader_lines": [1],
"text_reader_file": [1],
}
INSERT_LAST_CONTEXT_HINTS = (
"option",
"label",
"line",
"prompt",
"instruction",
"intro",
"reward",
"message",
"title",
"menu",
)
PASS_THROUGH_TEXT_FUNCTIONS = {
"i18n_text",
"i18n_translate_speech_message",
}
SKIP_DIR_NAMES = {".git", "bloodshed", "docs", "skills", "nvgt-git"}
SKIP_FILE_NAMES = {"crash.log"}
def iter_nvgt_files() -> List[Path]:
files: List[Path] = []
entrypoints = [ROOT / "draugnorak.nvgt", ROOT / "excluded_sounds.nvgt"]
for entry in entrypoints:
if entry.exists():
files.append(entry)
source_roots = [ROOT / "src", ROOT / "libstorm-nvgt"]
for source_root in source_roots:
if not source_root.exists():
continue
for path in source_root.rglob("*.nvgt"):
rel = path.relative_to(ROOT)
if any(part in SKIP_DIR_NAMES for part in rel.parts):
continue
if path.name in SKIP_FILE_NAMES:
continue
files.append(path)
return sorted(set(files))
def is_identifier_char(ch: str) -> bool:
return ch.isalnum() or ch == "_"
def read_identifier_backward(text: str, before_index: int) -> str:
i = before_index
while i >= 0 and text[i].isspace():
i -= 1
end = i
while i >= 0 and is_identifier_char(text[i]):
i -= 1
start = i + 1
if end < start:
return ""
return text[start : end + 1]
def find_matching_paren(text: str, open_index: int) -> int:
depth = 0
in_string = False
escape = False
for i in range(open_index, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "(":
depth += 1
continue
if ch == ")":
depth -= 1
if depth == 0:
return i
continue
return -1
def split_top_level(expr: str, delimiter: str) -> List[str]:
parts: List[str] = []
depth_paren = 0
depth_bracket = 0
depth_brace = 0
in_string = False
escape = False
start = 0
for i, ch in enumerate(expr):
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
continue
if ch == "(":
depth_paren += 1
continue
if ch == ")":
depth_paren = max(0, depth_paren - 1)
continue
if ch == "[":
depth_bracket += 1
continue
if ch == "]":
depth_bracket = max(0, depth_bracket - 1)
continue
if ch == "{":
depth_brace += 1
continue
if ch == "}":
depth_brace = max(0, depth_brace - 1)
continue
if ch == delimiter and depth_paren == 0 and depth_bracket == 0 and depth_brace == 0:
parts.append(expr[start:i])
start = i + 1
parts.append(expr[start:])
return parts
def unescape_string_literal(literal_body: str) -> str:
result: List[str] = []
i = 0
while i < len(literal_body):
ch = literal_body[i]
if ch != "\\":
result.append(ch)
i += 1
continue
if i + 1 >= len(literal_body):
result.append("\\")
break
nxt = literal_body[i + 1]
if nxt == "n":
result.append("\n")
elif nxt == "r":
result.append("\r")
elif nxt == "t":
result.append("\t")
else:
result.append(nxt)
i += 2
return "".join(result)
def expression_to_template(expr: str) -> Optional[str]:
expr = expr.strip()
if not expr:
return None
parts = split_top_level(expr, "+")
template_parts: List[str] = []
placeholder_count = 0
for raw_part in parts:
part = raw_part.strip()
if not part:
continue
passthrough_expr = unwrap_passthrough_expression(part)
if passthrough_expr is not None:
passthrough_template = expression_to_template(passthrough_expr)
if passthrough_template is not None:
template_parts.append(passthrough_template)
continue
if len(part) >= 2 and part[0] == '"' and part[-1] == '"':
template_parts.append(unescape_string_literal(part[1:-1]))
else:
placeholder_count += 1
template_parts.append(f"{{arg{placeholder_count}}}")
if not template_parts:
return None
template = "".join(template_parts)
if template.strip() == "":
return None
if template_literal_length(template) == 0:
return None
return template
def unwrap_passthrough_expression(expr: str) -> Optional[str]:
expr = expr.strip()
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$", expr, re.DOTALL)
if not match:
return None
function_name = match.group(1)
if function_name not in PASS_THROUGH_TEXT_FUNCTIONS:
return None
arg_text = match.group(2)
args = split_top_level(arg_text, ",")
if not args:
return None
return args[0].strip()
def template_literal_length(template_text: str) -> int:
literal_count = 0
in_placeholder = False
for ch in template_text:
if not in_placeholder and ch == "{":
in_placeholder = True
continue
if in_placeholder and ch == "}":
in_placeholder = False
continue
if not in_placeholder:
literal_count += 1
return literal_count
def line_number_for_index(text: str, index: int) -> int:
return text.count("\n", 0, index) + 1
def add_entry(entries: Dict[str, Dict[str, object]], template: str, source_ref: str) -> None:
base_key = f"msg.{hashlib.sha1(template.encode('utf-8')).hexdigest()[:12]}"
key = base_key
suffix = 1
while key in entries and entries[key]["value"] != template:
key = f"{base_key}_{suffix}"
suffix += 1
if key not in entries:
entries[key] = {"value": template, "refs": [source_ref]}
else:
refs: List[str] = entries[key]["refs"] # type: ignore[assignment]
if source_ref not in refs:
refs.append(source_ref)
def extract_from_call(
entries: Dict[str, Dict[str, object]],
receiver: str,
function_name: str,
args: List[str],
source_ref_base: str,
) -> None:
target_arg_indexes: List[int] = []
if function_name in TARGET_FUNCTION_ARGS:
target_arg_indexes = TARGET_FUNCTION_ARGS[function_name]
elif function_name == "insert_last":
receiver_lower = receiver.lower()
if not any(hint in receiver_lower for hint in INSERT_LAST_CONTEXT_HINTS):
return
target_arg_indexes = [0]
else:
return
for arg_index in target_arg_indexes:
if arg_index >= len(args):
continue
template = expression_to_template(args[arg_index])
if not template:
continue
source_ref = f"{source_ref_base}:{function_name}[{arg_index}]"
add_entry(entries, template, source_ref)
def scan_file(path: Path, entries: Dict[str, Dict[str, object]]) -> None:
text = path.read_text(encoding="utf-8", errors="replace")
i = 0
while i < len(text):
ch = text[i]
if not is_identifier_char(ch):
i += 1
continue
start = i
while i < len(text) and is_identifier_char(text[i]):
i += 1
name = text[start:i]
j = i
while j < len(text) and text[j].isspace():
j += 1
if j >= len(text) or text[j] != "(":
continue
receiver = ""
k = start - 1
while k >= 0 and text[k].isspace():
k -= 1
if k >= 0 and text[k] == ".":
receiver = read_identifier_backward(text, k - 1)
close = find_matching_paren(text, j)
if close < 0:
break
arg_text = text[j + 1 : close]
args = split_top_level(arg_text, ",")
line = line_number_for_index(text, start)
rel_path = path.relative_to(ROOT).as_posix()
source_ref_base = f"{rel_path}:{line}"
extract_from_call(entries, receiver, name, args, source_ref_base)
i = close + 1
def slugify_fragment(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", value.lower())
slug = slug.strip("_")
return slug or "value"
def add_item_registry_fragments(entries: Dict[str, Dict[str, object]]) -> None:
item_registry_path = ROOT / "src" / "item_registry.nvgt"
if not item_registry_path.exists():
return
text = item_registry_path.read_text(encoding="utf-8", errors="replace")
pattern = re.compile(
r"ItemDefinition\([^,]+,\s*\"([^\"]+)\",\s*\"([^\"]+)\",\s*\"([^\"]+)\"",
re.MULTILINE,
)
for match in pattern.finditer(text):
plural = match.group(1)
singular = match.group(2)
display = match.group(3)
plural_slug = slugify_fragment(plural)
singular_slug = slugify_fragment(singular)
display_slug = slugify_fragment(display)
add_entry(entries, plural, f"src/item_registry.nvgt:item_plural:{plural_slug}")
add_entry(entries, singular, f"src/item_registry.nvgt:item_singular:{singular_slug}")
add_entry(entries, display, f"src/item_registry.nvgt:item_display:{display_slug}")
def add_seed_messages(entries: Dict[str, Dict[str, object]]) -> None:
seeds = [
"Load Game (no saves found)",
"No saves found.",
"Unable to load save.",
"Unhandled exception",
"Really exit?",
"Found a stone.",
"Found a reed.",
"Found clay.",
]
for value in seeds:
add_entry(entries, value, "seed:manual")
def normalize_learn_sounds_label(sound_path: Path) -> str:
name = sound_path.stem.lower()
name = name.replace("_", " ").replace("-", " ")
name = re.sub(r"\s+", " ", name).strip()
return name or "sound"
def add_learn_sounds_label_seeds(entries: Dict[str, Dict[str, object]]) -> None:
sounds_dir = ROOT / "sounds"
if not sounds_dir.exists():
return
for path in sorted(sounds_dir.rglob("*")):
if not path.is_file():
continue
if path.suffix.lower() not in {".ogg", ".wav"}:
continue
label = normalize_learn_sounds_label(path)
rel = path.relative_to(ROOT).as_posix()
add_entry(entries, label, f"seed:learn_sounds_label:{rel}")
def escape_for_ini(value: str) -> str:
escaped = value.replace("\\", "\\\\")
escaped = escaped.replace("\n", "\\n")
escaped = escaped.replace("\r", "\\r")
escaped = escaped.replace("\t", "\\t")
return escaped
def write_catalog(entries: Dict[str, Dict[str, object]], output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
manual_entries: List[Tuple[str, str]] = [
("meta.code", "en"),
("meta.name", "English"),
("meta.native_name", "English"),
("system.language.select_prompt", "Select your language."),
("system.language.selected", "Language set to {language}."),
("system.language.english_label", "English (en)"),
("system.ui.window_title", "Draugnorak"),
("system.sex.male", "Male"),
("system.sex.female", "Female"),
("system.new_character.choose_sex", "Choose your sex."),
("system.new_character.enter_name", "Enter your name or press Enter for random."),
("system.new_character.save_exists_overwrite", "Save found for {name}. Overwrite?"),
("system.load_game.option_with_metadata", "{name}, {sex}, day {day}"),
("system.load_game.delete_confirm_base", "Are you sure you want to delete the character {name}?"),
("system.load_game.delete_confirm_with_metadata",
"Are you sure you want to delete the character {name} gender {sex} days {day}?"),
("system.load_game.delete_save_heading", "Delete Save"),
("system.load_game.delete_save_failed", "Unable to delete save."),
("system.quick_slot.no_item_bound", "No item bound to slot {slot}."),
("system.menu.closed", "Closed."),
("system.menu.canceled", "Canceled."),
("system.menu.no_options", "No options."),
("system.menu.no_matches", "No matches for {arg1}."),
("system.option.no", "No"),
("system.option.yes", "Yes"),
("system.inventory.cant_carry_any_more_item", "You can't carry any more {item}."),
("system.inventory.menu.prompt", "Inventory menu. {option}"),
("system.inventory.menu.no_options", "Inventory menu. No options."),
("system.inventory.option.personal", "Personal inventory"),
("system.inventory.option.base_storage", "Base storage"),
("system.inventory.count_set_to_slot", "{name} count set to slot {slot}."),
("system.inventory.need_quiver_for_arrows", "You need a quiver to carry arrows."),
("system.search.found_item", "Found {item}."),
("system.search.found_nothing", "Found nothing."),
("system.pickup.item", "Picked up {item}."),
("system.storage.window_title", "Inventory"),
("system.storage.transfer_prompt", "{prompt} (max {max})"),
("system.storage.deposit_how_many", "Deposit how many?"),
("system.storage.withdraw_how_many", "Withdraw how many?"),
("system.storage.nothing_to_deposit", "Nothing to deposit."),
("system.storage.nothing_to_withdraw", "Nothing to withdraw."),
("system.storage.runed_cannot_deposit", "Runed items cannot be deposited into storage."),
("system.storage.item_full", "Storage for that item is full."),
("system.storage.no_storage_built", "No storage built."),
("system.storage.menu_title", "Base storage."),
("system.storage.menu.prompt", "Base storage. {option}"),
("system.storage.menu.no_options", "Base storage. No options."),
("system.crafting.require.fire_within_three_clay_pot",
"You need a fire within 3 tiles to craft a clay pot."),
("system.crafting.require.fire_within_three_clay_pots",
"You need a fire within 3 tiles to craft clay pots."),
("system.crafting.require.fire_within_three_bowstring",
"You need a fire within 3 tiles to make bowstring."),
("system.crafting.require.altar_incense", "You need an altar to craft incense."),
("system.crafting.require.fire_within_three_smoke_fish",
"You need a fire within 3 tiles to smoke fish."),
("system.crafting.require.fire_within_three_butcher", "You need a fire within 3 tiles to butcher."),
("system.character.slot.head", "head"),
("system.character.slot.torso", "torso"),
("system.character.slot.arms", "arms"),
("system.character.slot.hands", "hands"),
("system.character.slot.legs", "legs"),
("system.character.slot.feet", "feet"),
("system.character.menu.prompt", "Character info. {option}"),
("system.character.menu.no_options", "Character info. No options."),
("system.character.pet.no_pet", "No pet."),
("system.character.pet.abandon_confirm", "Really abandon your pet? {option}"),
("system.character.pet.unconscious_cannot_abandon",
"Your {pet} is unconscious. You can't abandon it right now."),
("system.item.label.items", "items"),
("system.item.label.item", "item"),
("system.item.label.unknown", "Unknown"),
("system.equipment.menu.equipped_suffix", " (equipped)"),
("system.equipment.name.none", "None"),
("system.equipment.name.unknown", "Unknown"),
("system.equipment.name.spear", "Spear"),
("system.equipment.name.stone_axe", "Stone Axe"),
("system.equipment.name.sling", "Sling"),
("system.equipment.name.bow", "Bow"),
("system.equipment.name.skin_hat", "Skin Hat"),
("system.equipment.name.skin_gloves", "Skin Gloves"),
("system.equipment.name.skin_pants", "Skin Pants"),
("system.equipment.name.skin_tunic", "Skin Tunic"),
("system.equipment.name.moccasins", "Moccasins"),
("system.equipment.name.skin_pouch", "Skin Pouch"),
("system.equipment.name.backpack", "Backpack"),
("system.equipment.name.fishing_pole", "Fishing Pole"),
("system.equipment.name_plural.spears", "Spears"),
("system.equipment.name_plural.stone_axes", "Stone Axes"),
("system.equipment.name_plural.slings", "Slings"),
("system.equipment.name_plural.bows", "Bows"),
("system.equipment.name_plural.skin_hats", "Skin Hats"),
("system.equipment.name_plural.skin_gloves", "Skin Gloves"),
("system.equipment.name_plural.skin_pants", "Skin Pants"),
("system.equipment.name_plural.skin_tunics", "Skin Tunics"),
("system.equipment.name_plural.moccasins", "Moccasins"),
("system.equipment.name_plural.skin_pouches", "Skin Pouches"),
("system.equipment.name_plural.backpacks", "Backpacks"),
("system.equipment.name_plural.fishing_poles", "Fishing Poles"),
("system.equipment.name_plural.items", "Items"),
("system.environment.tree.fell", "Tree fell!"),
("system.environment.tree.fell_with_loot",
"Tree fell! Got {sticks} {sticks_label}, {vines} {vines_label}, and {logs} {logs_label}."),
("system.environment.tree.inventory_full", "Inventory full."),
("system.crafting.menu.prompt", "Crafting menu. {option}"),
("system.crafting.category.weapons", "Weapons"),
("system.crafting.category.tools", "Tools"),
("system.crafting.category.materials", "Materials"),
("system.crafting.category.clothing", "Clothing"),
("system.crafting.category.buildings", "Buildings"),
("system.crafting.category.barricade", "Barricade"),
("system.crafting.category.runes", "Runes"),
("system.crafting.weapons.prompt", "Weapons. {option}"),
("system.crafting.weapons.option.spear", "Spear (1 Stick, 1 Vine, 1 Stone) [Requires Knife]"),
("system.crafting.weapons.option.sling", "Sling (1 Skin, 2 Vines)"),
("system.crafting.weapons.option.bow", "Bow (1 Stick, 1 Bowstring)"),
("system.crafting.tools.prompt", "Tools. {option}"),
("system.crafting.tools.option.stone_knife", "Stone Knife (2 Stones)"),
("system.crafting.tools.option.snare", "Snare (1 Stick, 2 Vines)"),
("system.crafting.tools.option.stone_axe", "Stone Axe (1 Stick, 1 Vine, 2 Stones) [Requires Knife]"),
("system.crafting.tools.option.fishing_pole", "Fishing Pole (1 Stick, 2 Vines)"),
("system.crafting.tools.option.rope", "Rope (3 Vines)"),
("system.crafting.tools.option.quiver", "Quiver (2 Skins, 2 Vines)"),
("system.crafting.tools.option.canoe", "Canoe (4 Logs, 11 Sticks, 11 Vines, 6 Skins, 2 Rope, 6 Reeds)"),
("system.crafting.tools.option.reed_basket", "Reed Basket (3 Reeds)"),
("system.crafting.tools.option.clay_pot", "Clay Pot (3 Clay)"),
("system.crafting.materials.prompt", "Materials. {option}"),
("system.crafting.materials.option.butcher_game", "Butcher Game [Requires Game, Knife, Fire nearby]"),
("system.crafting.materials.option.smoke_fish", "Smoke Fish (1 Fish, 1 Stick) [Requires Fire nearby]"),
("system.crafting.materials.option.arrows", "Arrows (2 Sticks, 4 Feathers, 2 Stones) [Requires Quiver]"),
("system.crafting.materials.option.bowstring", "Bowstring (3 Sinew) [Requires Fire nearby]"),
("system.crafting.materials.option.incense", "Incense (6 Sticks, 2 Vines, 1 Reed) [Requires Altar]"),
("system.crafting.clothing.prompt", "Clothing. {option}"),
("system.crafting.clothing.option.skin_hat", "Skin Hat (1 Skin, 1 Vine)"),
("system.crafting.clothing.option.skin_gloves", "Skin Gloves (1 Skin, 1 Vine)"),
("system.crafting.clothing.option.skin_pants", "Skin Pants (6 Skins, 3 Vines)"),
("system.crafting.clothing.option.skin_tunic", "Skin Tunic (4 Skins, 2 Vines)"),
("system.crafting.clothing.option.moccasins", "Moccasins (2 Skins, 1 Vine)"),
("system.crafting.clothing.option.skin_pouch", "Skin Pouch (2 Skins, 1 Vine)"),
("system.crafting.clothing.option.backpack", "Backpack (11 Skins, 5 Vines, 4 Skin Pouches)"),
("system.crafting.buildings.prompt", "Buildings. {option}"),
("system.crafting.buildings.option.firepit", "Firepit (9 Stones)"),
("system.crafting.buildings.option.fire", "Fire (2 Sticks, 1 Log) [Requires Firepit]"),
("system.crafting.buildings.option.herb_garden", "Herb Garden (9 Stones, 3 Vines, 2 Logs) [Base Only]"),
("system.crafting.buildings.option.storage_upgrade_1",
"Upgrade Storage (6 Logs, 9 Stones, 8 Vines) [Base Only, 50 each]"),
("system.crafting.buildings.option.storage_upgrade_2",
"Upgrade Storage (12 Logs, 18 Stones, 16 Vines) [Base Only, 100 each]"),
("system.crafting.buildings.option.pasture", "Pasture (8 Logs, 18 Ropes) [Base Only, Requires Storage Upgrade]"),
("system.crafting.buildings.option.stable",
"Stable (10 Logs, 15 Stones, 10 Vines) [Base Only, Requires Storage Upgrade]"),
("system.crafting.buildings.option.altar", "Altar (9 Stones, 3 Sticks) [Base Only]"),
("system.crafting.barricade.prompt", "Barricade. {option}"),
("system.crafting.barricade.option.reinforce_sticks",
"Reinforce with sticks ({cost} sticks, +{health} health)"),
("system.crafting.barricade.option.reinforce_vines", "Reinforce with vines ({cost} vines, +{health} health)"),
("system.crafting.barricade.option.reinforce_log", "Reinforce with log ({cost} log, +{health} health)"),
("system.crafting.barricade.option.reinforce_stones",
"Reinforce with stones ({cost} stones, +{health} health)"),
("system.crafting.missing", "Missing: {requirements}"),
("system.crafting.requirement.stone_knife", "Stone Knife"),
("system.crafting.requirement.game", "Game"),
("system.crafting.requirement.favor", "favor"),
("system.crafting.requirement.resources", "resources"),
]
lines: List[str] = []
lines.append("; Draugnorak localization catalog")
lines.append("; Copy this file to lang/<code>.ini and translate only the right-hand values.")
lines.append("; Keep keys unchanged. Use placeholders like {arg1}, {count}, {language} exactly as written.")
lines.append("")
lines.append("[meta]")
for key, value in manual_entries[:3]:
lines.append(f"{key.split('.', 1)[1]}={escape_for_ini(value)}")
lines.append("")
lines.append("[system]")
for key, value in manual_entries[3:]:
lines.append(f"{key.split('.', 1)[1]}={escape_for_ini(value)}")
lines.append("")
lines.append("[messages]")
for key in sorted(entries.keys()):
value = entries[key]["value"]
refs: List[str] = entries[key]["refs"] # type: ignore[assignment]
for ref in refs[:3]:
lines.append(f"; {ref}")
lines.append(f"{key}={escape_for_ini(str(value))}")
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> None:
entries: Dict[str, Dict[str, object]] = {}
for file_path in iter_nvgt_files():
scan_file(file_path, entries)
add_item_registry_fragments(entries)
add_seed_messages(entries)
add_learn_sounds_label_seeds(entries)
en_path = ROOT / "lang" / "en.ini"
template_path = ROOT / "lang" / "en.template.ini"
write_catalog(entries, en_path)
write_catalog(entries, template_path)
print(f"Wrote {en_path.relative_to(ROOT)} with {len(entries)} generated entries")
print(f"Wrote {template_path.relative_to(ROOT)}")
if __name__ == "__main__":
main()