feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
@@ -51,6 +51,7 @@ class LineInfo:
|
||||
scene: str | None # logical path of the background shown at this line
|
||||
prev: str | None = None
|
||||
next: str | None = None
|
||||
resource: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -94,13 +95,14 @@ def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIn
|
||||
idx.strings.setdefault(item["text"], "menu")
|
||||
elif op == "show" and isinstance((node.get("img") or {}).get("text"), str):
|
||||
idx.strings.setdefault(node["img"]["text"], "text")
|
||||
elif op == "say" and node.get("tid"):
|
||||
elif op in {"say", "nani_add_choice"} and node.get("tid"):
|
||||
who = node.get("whoStr") or ""
|
||||
if not who and node.get("who"):
|
||||
c = chars.get(node["who"], {})
|
||||
who = c.get("name") if isinstance(c.get("name"), str) else node["who"]
|
||||
info = LineInfo(node["tid"], node.get("id", ""), path, group, who or "",
|
||||
node["what"], scene)
|
||||
info.resource = node.get("resource")
|
||||
if idx.lines and idx.lines[-1].module == path:
|
||||
idx.lines[-1].next = info.source
|
||||
info.prev = idx.lines[-1].source
|
||||
@@ -227,6 +229,8 @@ def apply_changes(manifest: dict[str, Any], lang: str, *, lines: dict[str, Any]
|
||||
table = dict(file_json(manifest, path) or {}) if path in files else {}
|
||||
for tid, value in changes.items():
|
||||
e = entry(value, idx.by_tid[tid].source) if value else None
|
||||
if e and game.get("engine") == "naninovel":
|
||||
validate_nani_translation(idx.by_tid[tid], e["t"])
|
||||
if e:
|
||||
table[tid] = e
|
||||
else:
|
||||
@@ -265,6 +269,19 @@ def apply_changes(manifest: dict[str, Any], lang: str, *, lines: dict[str, Any]
|
||||
return manifest
|
||||
|
||||
|
||||
def validate_nani_translation(info: LineInfo, text: str) -> None:
|
||||
from collections import Counter
|
||||
expressions = r"\{[^{}]+\}|\\[nrt]"
|
||||
if Counter(re.findall(expressions, info.source)) != Counter(re.findall(expressions, text)):
|
||||
raise ValueError(f"Изменены выражения или управляющие последовательности Naninovel: {info.tid}")
|
||||
tags = r"</?[^<>]+>"
|
||||
if re.findall(tags, info.source) != re.findall(tags, text):
|
||||
raise ValueError(f"Изменены теги Naninovel: {info.tid}")
|
||||
for delimiter in (info.resource or {}).get("delimiters", ""):
|
||||
if text.count(delimiter) != info.source.count(delimiter):
|
||||
raise ValueError(f"Изменён разделитель текстового ресурса Naninovel: {info.tid}")
|
||||
|
||||
|
||||
def merge_languages(target: dict[str, Any], source: dict[str, Any], langs: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Copy whole languages from one manifest into another (publishing a translation)."""
|
||||
@@ -307,6 +324,39 @@ def changed_languages(fork: dict[str, Any], base: dict[str, Any] | None) -> list
|
||||
return out
|
||||
|
||||
|
||||
def merge_translation(base: dict, source: dict, target: dict) -> tuple[dict, list]:
|
||||
"""Three-way publication of individual locale entries, preserving source hashes."""
|
||||
from app.mods import merge_value
|
||||
|
||||
def tree(manifest):
|
||||
game = file_json(manifest, "game.json")
|
||||
infos = {lang: {k: v for k, v in info.items() if k not in ("progress", "lines", "strings_count")}
|
||||
for lang, info in game.get("locales", {}).items()}
|
||||
files = {p: ({"json": file_json(manifest, p)} if p.endswith(".json") else entry)
|
||||
for p, entry in manifest["files"].items() if p.startswith("locales/")}
|
||||
return {"locales": infos, "files": files}
|
||||
|
||||
conflicts = []
|
||||
merged = merge_value(tree(base), tree(source), tree(target), "", conflicts, {})
|
||||
if conflicts:
|
||||
return target, conflicts
|
||||
files = {p: f for p, f in target["files"].items() if not p.startswith("locales/")}
|
||||
for path, entry in merged["files"].items():
|
||||
if set(entry) == {"json"}:
|
||||
put_json(files, path, entry["json"])
|
||||
else:
|
||||
files[path] = entry
|
||||
game = copy.deepcopy(file_json(target, "game.json"))
|
||||
game["locales"] = merged["locales"]
|
||||
put_json(files, "game.json", game)
|
||||
manifest = {**target, "files": files}
|
||||
for lang, info in game["locales"].items():
|
||||
st = stats(manifest, lang)
|
||||
info.update(lines=st["translated"], progress=st["progress"], strings_count=st["stringsTranslated"])
|
||||
put_json(files, "game.json", game)
|
||||
return manifest, []
|
||||
|
||||
|
||||
# --- PO / CSV --------------------------------------------------------------------------
|
||||
|
||||
def _po_quote(s: str) -> str:
|
||||
|
||||
Reference in new issue
Block a user