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:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+51 -1
View File
@@ -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: