DazedTL/util/actor_substitutor.py
2026-03-12 20:20:17 -05:00

248 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Actor Variable Substitutor
RPGMaker uses \\n[X] (stored as \\n[X] in JSON, single backslash after json.load)
to pull actor names dynamically at runtime. This module:
1. Builds a map of actor_id -> name from Actors.json
(prefers translated/Actors.json over files/Actors.json so the AI
receives the target-language name in context)
2. substitute_in_files() walks every .json file inside `files/`,
replaces \\n[X] with the actor's name, writes the file back.
Returns per-file statistics.
3. restore_in_translated() walks every .json file inside `translated/`,
replaces the substituted name back with \\n[X].
Returns per-file statistics.
The rename-able protagonist case is handled automatically: substitute the
default/translated name for context during translation, then restore the
\\n[X] variable so the player's custom name still works at runtime.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Optional
# Pattern that matches \n[X] after json.load() (single literal backslash)
_VAR_RE = re.compile(r"\\n\[(\d+)\]", re.IGNORECASE)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def load_actor_map(
files_dir: str | Path = "files",
translated_dir: str | Path = "translated",
) -> dict[int, str]:
"""Return {actor_id: name}.
Prefers translated/Actors.json (English names) over files/Actors.json.
"""
for candidates in (
Path(translated_dir) / "Actors.json",
Path(files_dir) / "Actors.json",
):
if candidates.is_file():
try:
with open(candidates, "r", encoding="utf-8-sig") as fh:
data = json.load(fh)
actor_map: dict[int, str] = {}
for entry in data:
if not entry or not isinstance(entry, dict):
continue
actor_id = entry.get("id")
name = (entry.get("name") or "").strip()
if actor_id is not None and name:
actor_map[int(actor_id)] = name
if actor_map:
return actor_map
except Exception:
continue
return {}
def substitute_in_files(
files_dir: str | Path = "files",
actor_map: Optional[dict[int, str]] = None,
translated_dir: str | Path = "translated",
) -> dict:
"""Replace \\n[X] with actor names in all .json files inside *files_dir*.
If *actor_map* is None it is built automatically via load_actor_map().
Returns a summary dict:
{
"files_modified": int,
"total_substitutions": int,
"variables_found": {actor_id: name, ...},
"errors": [str, ...]
}
"""
files_dir = Path(files_dir)
if actor_map is None:
actor_map = load_actor_map(files_dir, translated_dir)
stats = {
"files_modified": 0,
"total_substitutions": 0,
"variables_found": {},
"errors": [],
}
for fp in sorted(files_dir.glob("*.json")):
try:
original = fp.read_text(encoding="utf-8-sig")
data = json.loads(original)
count = [0]
found: dict[int, str] = {}
new_data = _walk(data, actor_map, count, found, substitute=True)
if count[0] > 0:
stats["total_substitutions"] += count[0]
stats["files_modified"] += 1
stats["variables_found"].update(found)
out = json.dumps(new_data, ensure_ascii=False, indent=4)
fp.write_text(out + "\n", encoding="utf-8")
except Exception as exc:
stats["errors"].append(f"{fp.name}: {exc}")
return stats
def restore_in_translated(
translated_dir: str | Path = "translated",
actor_map: Optional[dict[int, str]] = None,
files_dir: str | Path = "files",
) -> dict:
"""Replace actor names back with \\n[X] in all .json files inside *translated_dir*.
If *actor_map* is None it is built via load_actor_map() which prefers
translated/Actors.json (the already-translated names).
Returns a summary dict with the same shape as substitute_in_files().
"""
translated_dir = Path(translated_dir)
if actor_map is None:
actor_map = load_actor_map(files_dir, translated_dir)
# Build reverse map: name (lowercased) → "\\n[X]"
# We match case-insensitively so the AI's casing doesn't break restore.
# We keep the original-case name for display in stats.
reverse: dict[str, tuple[str, int]] = {} # lower_name -> (var_string, actor_id)
for aid, name in actor_map.items():
if name:
reverse[name.lower()] = (f"\\n[{aid}]", aid)
stats = {
"files_modified": 0,
"total_substitutions": 0,
"variables_found": {},
"errors": [],
}
for fp in sorted(translated_dir.glob("*.json")):
# Never restore inside Actors.json itself — that file has the names
# as first-class translated data, not as variable stand-ins.
if fp.name == "Actors.json":
continue
try:
data = json.loads(fp.read_text(encoding="utf-8-sig"))
count = [0]
found: dict[int, str] = {}
new_data = _walk_restore(data, reverse, count, found)
if count[0] > 0:
stats["total_substitutions"] += count[0]
stats["files_modified"] += 1
stats["variables_found"].update(found)
out = json.dumps(new_data, ensure_ascii=False, indent=4)
fp.write_text(out + "\n", encoding="utf-8")
except Exception as exc:
stats["errors"].append(f"{fp.name}: {exc}")
return stats
def scan_variables(
files_dir: str | Path = "files",
) -> dict[int, int]:
"""Scan *files_dir* and return {actor_id: occurrence_count} without modifying files."""
counts: dict[int, int] = {}
for fp in Path(files_dir).glob("*.json"):
try:
text = fp.read_text(encoding="utf-8-sig")
# Search in raw JSON text (\\n[ in file = single \n[ after parse)
# The raw JSON file stores it as \\n[X] so we search that directly:
for m in re.finditer(r"\\\\n\[(\d+)\]", text):
aid = int(m.group(1))
counts[aid] = counts.get(aid, 0) + 1
except Exception:
pass
return counts
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _sub_string(s: str, actor_map: dict[int, str], count: list, found: dict) -> str:
def replace(m: re.Match) -> str:
aid = int(m.group(1))
name = actor_map.get(aid)
if name:
count[0] += 1
found[aid] = name
return name
return m.group(0)
return _VAR_RE.sub(replace, s)
def _restore_string(s: str, reverse: dict[str, tuple[str, int]], count: list, found: dict) -> str:
"""Replace actor names with \\n[X] in a translated string."""
# We need to handle the fact that names can be substrings of other words.
# Build a single regex alternation sorted by length (longest first) to
# prevent short names like "Al" matching inside "Alicia".
if not reverse:
return s
pattern = re.compile(
"|".join(re.escape(name) for name in sorted(reverse.keys(), key=len, reverse=True)),
re.IGNORECASE,
)
def replace(m: re.Match) -> str:
lower_matched = m.group(0).lower()
entry = reverse.get(lower_matched)
if entry:
var_str, aid = entry
count[0] += 1
found[aid] = m.group(0)
return var_str
return m.group(0)
return pattern.sub(replace, s)
def _walk(obj, actor_map, count, found, substitute: bool):
if isinstance(obj, str):
return _sub_string(obj, actor_map, count, found)
if isinstance(obj, list):
return [_walk(item, actor_map, count, found, substitute) for item in obj]
if isinstance(obj, dict):
return {k: _walk(v, actor_map, count, found, substitute) for k, v in obj.items()}
return obj
def _walk_restore(obj, reverse, count, found):
if isinstance(obj, str):
return _restore_string(obj, reverse, count, found)
if isinstance(obj, list):
return [_walk_restore(item, reverse, count, found) for item in obj]
if isinstance(obj, dict):
return {k: _walk_restore(v, reverse, count, found) for k, v in obj.items()}
return obj