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

+214
View File
@@ -0,0 +1,214 @@
"""Compile the supported Naninovel command subset to the shared PGVN player."""
from collections import Counter
import hashlib
import re
from app.converter import nani_expr
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.rpy_parser import ScriptData
def target(path, script: str) -> str:
if isinstance(path, dict):
return "nani:" + str(path.get("name") or script) + ("." + str(path["value"]) if path.get("value") else "")
if not isinstance(path, str) or "{" in path:
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
return "nani:" + (script + path if path.startswith(".") else path)
def compile_package(package) -> tuple[ScriptData, str, dict]:
data, expressions = ScriptData(), {}
counts, locations = Counter(), {}
def issue(message, location):
counts[message] += 1
locations.setdefault(message, location)
def text(value):
value = value.get("template", "") if isinstance(value, dict) else value
if not isinstance(value, str):
raise ValueError("Ожидался текст Naninovel")
for match in re.finditer(r"\{([^{}]+)\}", value):
expressions[match[1]] = nani_expr.parse(match[1])
# Rich text is parsed by the browser. Inline script commands must have
# been decoded as commands, rather than silently displayed as prose.
if re.search(r"\[(?:@|goto\b|set\b|wait\b|char\b|back\b)", value):
raise ValueError("Неподдерживаемая встроенная команда в тексте Naninovel")
for tag in re.findall(r"</?[A-Za-z][^<>]*>", value):
if not re.fullmatch(r"</?(?:b|i|u|s)>|<(?:color|size)=[^<>]+>|</(?:color|size)>|<br\s*/?>", tag, re.I):
raise ValueError(f"Не поддерживается текстовый тег Naninovel: {tag}")
return value
def static(value, name="параметр"):
if isinstance(value, dict) and "template" in value:
raise ValueError(f"Динамический {name} Naninovel пока не поддерживается")
return value
def expression(value):
return nani_expr.expr(static(value, "сценарное выражение"))
all_targets, all_names = [], set()
for script_name, script in package.scripts.items():
if (not re.fullmatch(r"[\w/.-]+", script_name) or script_name.startswith(("/", ".")) or
".." in script_name.split("/")):
issue("Некорректное имя сценария Naninovel", script_name)
continue
body, extras, stack = [], [], []
current = body
choice_count = 0
for line in script["lines"]:
location = f"{script_name}:{line['line']}"
if line.get("label"):
current.append({"op": "label", "name": f"nani:{script_name}.{line['label']}"})
for number, command in enumerate(line["commands"]):
op, args = command["op"], dict(command["args"])
identity = hashlib.sha256(f"{script_name}:{line['line']}:{number}".encode()).hexdigest()[:24]
try:
condition = args.pop("ConditionalExpression", None)
wait = args.pop("Wait", True)
if wait is not True and wait != 1:
raise ValueError("Асинхронные команды Naninovel (wait:false) пока не поддерживаются")
if op == "BeginIf":
node = {"op": "if", "branches": [{"c": expression(args.pop("Expression")), "body": []}]}
current.append(node)
stack.append((current, node))
current = node["branches"][0]["body"]
nodes = []
elif op in {"Else", "ElseIf"}:
if not stack or stack[-1][1]["branches"][-1]["c"] is None:
raise ValueError("Несогласованные ветки if/else Naninovel")
branch = {"c": expression(args.pop("Expression")) if op == "ElseIf" else None, "body": []}
stack[-1][1]["branches"].append(branch)
current = branch["body"]
nodes = []
elif op == "EndIf":
if not stack:
raise ValueError("EndIf без BeginIf")
current, _ = stack.pop()
nodes = []
elif op in {"PrintText", "AppendText"}:
author = text(args.pop("AuthorId", ""))
reset = args.pop("ResetPrinter", op != "AppendText")
interact = bool(args.pop("WaitForInput", True))
reveal = static(args.pop("RevealSpeed", 1), "RevealSpeed")
if reveal != 1:
raise ValueError("Изменение RevealSpeed Naninovel пока не поддерживается")
nodes = [{"op": "say", "what": text(args.pop("Text", "")), "tid": "nani-" + identity,
**({"whoStr": author} if reset else {"who": "extend"}), "interact": interact}]
elif op == "SetCustomVariable":
nodes = [{"op": "exec", "code": [nani_expr.assignment(static(args.pop("Expression"), "присваивание"))]}]
elif op in {"Goto", "Gosub"}:
destination = target(args.pop("Path"), script_name)
all_targets.append((destination, location))
nodes = [{"op": "jump" if op == "Goto" else "call", "target": destination}]
elif op == "Return":
nodes = [{"op": "return"}]
elif op == "AddChoice":
destination = args.pop("GotoPath", None)
actions = args.pop("OnSelected", [])
if actions:
raise ValueError("Обработчики OnSelected Naninovel пока не поддерживаются")
if not destination:
raise ValueError("Выбор Naninovel без GotoPath пока не поддерживается")
destination = target(destination, script_name)
all_targets.append((destination, location))
node = {"op": "nani_add_choice", "what": text(args.pop("ChoiceSummary", "")),
"tid": "nani-" + identity, "target": destination,
"handler": static(args.pop("HandlerId", ""))}
nodes = [node]
choice_count += 1
elif op == "Stop":
if not choice_count:
raise ValueError("Stop Naninovel требует внешнего интерфейса или механики игры")
nodes, choice_count = [{"op": "nani_menu"}], 0
elif op == "ClearChoiceHandler":
nodes = [{"op": "nani_clear_choice", "handler": static(args.pop("HandlerId", "*"))}]
elif op == "Wait":
mode = str(static(args.pop("WaitMode", "0"), "WaitMode"))
if mode.startswith("i"):
raise ValueError("Прерываемая пауза Naninovel пока не поддерживается")
nodes = [{"op": "pause", "d": {"k": "const", "v": float(mode)}}]
elif op == "WaitForInput":
nodes = [{"op": "pause", "d": None}]
elif op in {"HidePrinter", "ShowPrinter"}:
nodes = [{"op": "window", "mode": "hide" if op == "HidePrinter" else "show"}]
elif op in {"PlayBgm", "PlaySfx", "PlayVoice"}:
key = {"PlayBgm": "BgmPath", "PlaySfx": "SfxPath", "PlayVoice": "VoicePath"}[op]
path = static(args.pop(key), key)
nodes = [{"op": "play", "ch": {"PlayBgm": "music", "PlaySfx": "sound", "PlayVoice": "voice"}[op],
"files": [{"k": "const", "v": path}], "loop": args.pop("Loop", op == "PlayBgm")}]
elif op in {"StopBgm", "StopSfx"}:
nodes = [{"op": "stop", "ch": "music" if op == "StopBgm" else "sound"}]
elif op in {"ModifyBackground", "ModifyCharacter"}:
spec = static(args.pop("AppearanceAndTransition" if op == "ModifyBackground" else "IdAndAppearance"))
if not isinstance(spec, dict) or not spec.get("name"):
raise ValueError(f"Не поддерживается адрес изображения {op}")
actor = str(spec["name"])
appearance = spec.get("value")
if op == "ModifyBackground" and appearance:
raise ValueError("Специальный переход Naninovel пока не поддерживается")
image = actor if op == "ModifyBackground" else actor + "/" + str(appearance or "Default")
key = "nani-bg" if op == "ModifyBackground" else "nani-char-" + actor
name = key + " " + image
data.images[name] = {"file": image}
props = {"xalign": 0.5, "yalign": 1}
if "ScenePosition" in args:
position = static(args.pop("ScenePosition"), "ScenePosition")
if not isinstance(position, list) or len(position) != 2:
raise ValueError("Неподдерживаемая позиция Naninovel")
props = {"xalign": position[0] / 100, "yalign": 1 - position[1] / 100}
duration = float(static(args.pop("Duration", 0), "Duration"))
nodes = [{"op": "show", "as": key, "img": {"name": name.split(" ")},
"at": [{"props": props}], "zorder": {"k": "const", "v": 0 if op == "ModifyBackground" else 10},
"with": {"t": "dissolve", "d": duration} if duration else None}]
elif op == "HideAllCharacters":
duration = float(static(args.pop("Duration", 0), "Duration"))
# The player preserves backgrounds and other image layers.
nodes = [{"op": "nani_hide_characters", "duration": duration}]
elif op == "ExitToTitle":
nodes = [{"op": "nani_exit"}]
else:
raise ValueError(f"Не поддерживается команда Naninovel: {op}")
if args:
raise ValueError(f"Не поддерживаются параметры {op}: {', '.join(sorted(args))}")
if condition is not None:
if not nodes:
raise ValueError("Условие на структурной команде Naninovel")
nodes = [{"op": "if", "branches": [{"c": expression(condition), "body": nodes}]}]
current.extend(nodes)
except (ValueError, KeyError, TypeError) as exc:
issue(str(exc), location)
if stack:
issue("Не закрыт BeginIf Naninovel", script_name)
labels = [{"name": "nani:" + script_name, "body": body}, *extras]
for label in labels:
all_names.add(label["name"])
def names(nodes):
for node in nodes:
if node["op"] == "label":
all_names.add(node["name"])
for branch in node.get("branches", []):
names(branch["body"])
names(label["body"])
data.modules[script_name + ".nani"] = labels
for destination, location in all_targets:
if destination not in all_names:
issue(f"Не найдена цель Naninovel: {destination}", location)
entry = next(("nani:" + name for name in ("Opening", "Main", "Start") if name in package.scripts), None)
if entry is None:
issue("Не определена точка входа Naninovel (Opening/Main/Start)", "")
# Init may include external functions or engine UI setup. Do not discard it.
if "Init" in package.scripts:
data.init_code = []
issue("Инициализация Naninovel требует отдельного адаптера Init", "Init")
issues = package.issues + [Issue("naninovel_command", msg, locations[msg], count)
for msg, count in counts.items()]
if issues:
raise CompatibilityError("naninovel", issues)
resources = []
for tid, record in package.texts.items():
resources.append({"op": "say", "what": record["text"], "tid": tid,
"whoStr": record["origin"], "resource": record})
if resources:
data.modules["resources.nani"] = [{"name": "nani:resources", "body": resources}]
return data, entry, expressions