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

+132
View File
@@ -0,0 +1,132 @@
"""Naninovel importer: compiled Unity data -> validated PGVN package."""
from pathlib import Path, PurePosixPath
import json
import re
import tempfile
from app.blobs import put_bytes, put_file
from app.converter import assets, locales
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.nani_compiler import compile_package
from app.converter.nani_package import read_package
def inspect_naninovel(root: Path, *, work: Path | None = None) -> dict:
if work is None:
with tempfile.TemporaryDirectory(prefix="pgames-naninovel-") as directory:
return inspect_naninovel(root, work=Path(directory))
package = read_package(root, work, media=True)
data, entry, _ = compile_package(package)
resolve_media(package, data)
return {"engine": "naninovel", "compatible": True, "scripts": len(package.scripts),
"textResources": len(package.texts), "entry": entry,
"commands": sum(len(line["commands"]) for script in package.scripts.values() for line in script["lines"]),
"warnings": dict(data.warnings)}
def resolve_media(package, data):
"""Resolve every resource before decoding or transcoding any of them."""
wanted = {image["file"] for image in data.images.values() if image.get("file")}
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
wanted.update(value["v"] for value in node["files"])
candidates = {}
for source, obj in package.resources:
try:
name = obj.peek_name()
except (ValueError, AttributeError):
continue
if name:
candidates.setdefault(name, []).append((source, obj))
resolved, issues = {}, []
for name in sorted(wanted):
matches = candidates.get(name, candidates.get(name.rsplit("/", 1)[-1], []))
if len(matches) != 1:
issues.append(Issue("ambiguous_media" if matches else "missing_media",
"Неоднозначный ресурс" if matches else "Не найден ресурс", name))
elif matches[0][1].type.name not in {"Texture2D", "AudioClip"}:
issues.append(Issue("unsupported_media", "Тип медиа пока не поддерживается", name))
else:
resolved[name] = matches[0]
if issues:
raise CompatibilityError("naninovel", issues)
return resolved
def export_media(package, data, root: Path, work: Path, progress):
resolved = resolve_media(package, data)
files, aliases, issues = {}, {}, []
out = work / "media"
out.mkdir(parents=True, exist_ok=True)
for index, (name, (source, obj)) in enumerate(resolved.items()):
progress(f"Медиа Naninovel {index + 1}/{len(resolved)}")
try:
asset = obj.read()
stem = str(index)
if obj.type.name == "Texture2D":
path = out / (stem + ".png")
asset.image.save(path)
elif obj.type.name == "AudioClip":
samples = asset.samples
if len(samples) != 1:
raise ValueError("Аудиоресурс не содержит единственный клип")
filename, raw = next(iter(samples.items()))
path = out / (stem + (Path(filename).suffix or ".wav"))
path.write_bytes(raw)
else:
raise ValueError(f"Тип медиа {obj.type.name} пока не поддерживается")
converted, ext = assets.convert(path, out)
digest, size = put_file(converted, ext, move=True)
logical = "media/" + stem + ext
files[logical] = {"hash": digest, "size": size}
aliases[name] = logical
except Exception as exc:
issues.append(Issue("media_error", f"Не удалось извлечь медиа: {exc}", name))
if issues:
raise CompatibilityError("naninovel", issues)
for image in data.images.values():
if image.get("file"):
image["file"] = aliases[image["file"]]
for modules in data.modules.values():
for label in modules:
for node in locales.walk(label["body"]):
if node["op"] == "play":
for value in node["files"]:
value["v"] = aliases[value["v"]]
return files, aliases
def convert_naninovel(root: Path, work: Path, progress):
from app.converter.pipeline import Result, anchors
progress("Чтение сценариев Naninovel и проверка совместимости")
package = read_package(root, work / "unity", media=True)
data, entry, expressions = compile_package(package)
# No media is decoded or written to the blob store before the script gate.
files, aliases = export_media(package, data, root, work, progress)
locales.assign_ids(data.modules)
scripts = []
def store(path, value):
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
digest, size = put_bytes(raw, ".json")
files[path] = {"hash": digest, "size": size}
for path, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(path).with_suffix(".json"))
store(logical, {"file": path, "labels": labels, "anchors": anchors(labels)})
scripts.append(logical)
source = locales.detect_language(data.modules) or "und"
game = {"format": "pgvn/1", "engine": "naninovel", "title": root.name,
"resolution": [1920, 1080], "entry": entry, "scripts": scripts,
"characters": data.characters, "images": data.images, "transforms": {},
"transitions": {}, "audio": {}, "files": aliases, "defaults": [], "init": [],
"ui": {}, "sourceLang": source, "locales": {}, "naniExpressions": expressions}
store("game.json", game)
total = sum(entry["size"] for entry in files.values())
report = {"engine": "naninovel", "compatible": True, "format": "pgvn/1",
"scripts": len(package.scripts), "textResources": len(package.texts),
"media": len(aliases), "warnings": []}
return Result({"format": "pgvn/1", "files": files}, report, root.name, None, total)