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
+3822 -65

No files matched your search

+3
View File
@@ -1,7 +1,10 @@
.env .env
data/ data/
node_modules/ node_modules/
.venv/
.tools/
dist/ dist/
*.tsbuildinfo
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.pyc *.pyc
+3
View File
@@ -8,6 +8,9 @@
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Редактор модов, слияние, перенос на новую версию, ревизии и сборщик мусора —
в [docs/MODDING.md](docs/MODDING.md).
## Установка на VM ## Установка на VM
```bash ```bash
+2
View File
@@ -2,3 +2,5 @@ __pycache__
.pytest_cache .pytest_cache
*.pyc *.pyc
.env .env
.venv
.tools
+7 -2
View File
@@ -5,8 +5,13 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# unrpyc v2 decompiles .rpyc from Ren'Py 6.18 – 8 on Python 3 # unrpyc v2 decompiles .rpyc from Ren'Py 6.18 – 8 on Python 3
ARG UNRPYC_REF=master ARG UNRPYC_REF=3ae8334ed71a05535927dcc559663d3aca51215b
RUN git clone --depth 1 --branch ${UNRPYC_REF} https://github.com/CensoredUsername/unrpyc /opt/unrpyc \ RUN git init /opt/unrpyc \
&& git -C /opt/unrpyc remote add origin https://github.com/CensoredUsername/unrpyc \
&& git -C /opt/unrpyc fetch --depth 1 origin ${UNRPYC_REF} \
&& git -C /opt/unrpyc checkout --detach FETCH_HEAD \
&& test "$(git -C /opt/unrpyc rev-parse HEAD)" = "${UNRPYC_REF}" \
&& printf '%s' "${UNRPYC_REF}" > /opt/unrpyc/REVISION \
&& rm -rf /opt/unrpyc/.git && rm -rf /opt/unrpyc/.git
WORKDIR /app WORKDIR /app
+7
View File
@@ -15,6 +15,8 @@ from collections import Counter
from pathlib import Path from pathlib import Path
from app.converter import assets, locales from app.converter import assets, locales
from app.converter.compatibility import Issue, audit_ir
from dataclasses import asdict
from app.converter.archive import ArchiveError, find_game_dir from app.converter.archive import ArchiveError, find_game_dir
from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors
from app.converter.rpa import RpaError, extract_rpa, read_index from app.converter.rpa import RpaError, extract_rpa, read_index
@@ -81,6 +83,7 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
data.warn("Слишком глубокая вложенность", rel) data.warn("Слишком глубокая вложенность", rel)
by_line = locales.assign_ids(data.modules) by_line = locales.assign_ids(data.modules)
audit_ir(data)
translations = locales.import_tl(work, data.modules, by_line, data.warn) translations = locales.import_tl(work, data.modules, by_line, data.warn)
says = len(by_line) or 1 says = len(by_line) or 1
language = data.config.get("language") language = data.config.get("language")
@@ -100,6 +103,10 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
_collect_targets(data.modules, targets) _collect_targets(data.modules, targets)
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v))) media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
return { return {
"issues": [asdict(Issue("unsupported_script", msg,
data.warning_examples.get(msg, ""), count))
for msg, count in data.blockers.items()] +
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
"game": src.name, "game": src.name,
"renpy": renpy_version(src), "renpy": renpy_version(src),
"seconds": round(time.monotonic() - started, 1), "seconds": round(time.monotonic() - started, 1),
+11 -5
View File
@@ -16,15 +16,16 @@ def _safe_target(root: Path, name: str) -> Path | None:
return target if target.is_relative_to(root.resolve()) else None return target if target.is_relative_to(root.resolve()) else None
def extract(archive: Path, filename: str, dest: Path) -> None: def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
dest.mkdir(parents=True, exist_ok=True) dest.mkdir(parents=True, exist_ok=True)
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive): if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
_extract_zip(archive, dest) _extract_zip(archive, dest, only_scripts=only_scripts)
return return
seven = shutil.which("7zz") or shutil.which("7z") seven = shutil.which("7zz") or shutil.which("7z")
if not seven: if not seven:
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен") raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
proc = subprocess.run([seven, "x", "-y", "-snl-", f"-o{dest}", str(archive)], filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
capture_output=True, text=True) capture_output=True, text=True)
if proc.returncode not in (0, 1): # 1 = warnings if proc.returncode not in (0, 1): # 1 = warnings
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}") raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
@@ -33,14 +34,19 @@ def extract(archive: Path, filename: str, dest: Path) -> None:
inner = list(dest.iterdir()) inner = list(dest.iterdir())
if len(inner) == 1 and inner[0].suffix == ".tar": if len(inner) == 1 and inner[0].suffix == ".tar":
tar = inner[0] tar = inner[0]
extract(tar, tar.name, dest / "_tar") extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
tar.unlink() tar.unlink()
def _extract_zip(archive: Path, dest: Path) -> None: SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
with zipfile.ZipFile(archive) as zf: with zipfile.ZipFile(archive) as zf:
for info in zf.infolist(): for info in zf.infolist():
name = info.filename name = info.filename
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
continue
if info.flag_bits & 0x800 == 0: if info.flag_bits & 0x800 == 0:
# legacy zips store names in cp437; many were really written as cp866/utf-8 # legacy zips store names in cp437; many were really written as cp866/utf-8
try: try:
+137
View File
@@ -0,0 +1,137 @@
"""Engine detection and fail-closed import diagnostics. Never runs game executables."""
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
import os
@dataclass(frozen=True)
class Issue:
code: str
message: str
location: str = ""
count: int = 1
class CompatibilityError(RuntimeError):
def __init__(self, engine: str, issues: list[Issue]):
self.engine, self.issues = engine, issues
super().__init__("Импорт остановлен: " + "; ".join(
i.message + (f" ({i.location})" if i.location else "") for i in issues[:5]))
def report(self) -> dict:
return {"error": str(self), "engine": self.engine, "compatible": False,
"issues": [asdict(i) for i in self.issues]}
@dataclass(frozen=True)
class Detection:
engine: str
root: Path
# Development copies and installed patches must not be mistaken for additional games.
IGNORED = {".git", ".godot", ".local-translator", "migration-staging", "migration-tools",
"node_modules", "__macosx"}
def detect(root: Path) -> Detection:
candidates: dict[tuple[str, Path], Detection] = {}
for directory, dirs, files in os.walk(root, followlinks=False):
dirs[:] = sorted(d for d in dirs if d.lower() not in IGNORED and
not Path(directory, d).is_symlink())
path = Path(directory)
names = {f.lower() for f in files}
lower = {d.lower() for d in dirs}
engine = None
if path.name.lower().endswith("_data") and ("managed" in lower or "il2cpp_data" in lower):
managed = path / next((d for d in dirs if d.lower() == "managed"), "Managed")
dlls = {p.name.lower() for p in managed.glob("*.dll")}
engine = "naninovel" if any("naninovel" in n for n in dlls) else "unity"
elif "data.win" in names:
engine = "gamemaker"
elif "project.godot" in names or any(n.endswith(".pck") for n in names):
engine = "godot"
elif "game.rgss3a" in names or "game.rgss2a" in names or "game.rgssad" in names:
engine = "rpgmaker"
elif "rpg_core.js" in names or "rmmz_core.js" in names:
engine = "rpgmaker"
elif path.name.lower() == "game" and any(
PurePosixPath(n).suffix in {".rpy", ".rpyc", ".rpa"} for n in names):
engine = "renpy"
if engine:
game_root = path.parent if engine in {"naninovel", "unity", "renpy"} else path
candidates[(engine, game_root)] = Detection(engine, game_root)
# Engine internals are not independent games.
dirs.clear()
if not candidates:
# Unpacked Ren'Py script-only archives do not always have a game/ wrapper.
from app.converter.archive import ArchiveError, find_game_dir
try:
game = find_game_dir(root)
return Detection("renpy", game)
except ArchiveError:
raise CompatibilityError("unknown", [Issue("unknown_engine",
"Движок не распознан; нужен полный дистрибутив игры, а не отдельный EXE или патч")]) from None
if len(candidates) != 1:
raise CompatibilityError("ambiguous", [Issue("multiple_games",
"В одном импорте обнаружено несколько игр; загрузите каждую отдельно",
", ".join(f"{c.engine}: {c.root.relative_to(root)}" for c in candidates.values()))])
result = next(iter(candidates.values()))
if result.engine not in {"renpy", "naninovel"}:
raise CompatibilityError(result.engine, [Issue("unsupported_engine",
f"Движок {result.engine} распознан, но его исполнение в PGames не поддерживается")])
return result
def require_supported_scripts(data, engine: str = "renpy") -> None:
issues = [Issue("unsupported_script", message, data.warning_examples.get(message, ""), count)
for message, count in data.blockers.items()]
if issues:
raise CompatibilityError(engine, issues)
CALLS = set("len str int float bool abs round min max sum any all range list tuple set dict sorted reversed enumerate zip isinstance _ __".split())
CALLS |= {prefix + method for prefix in ("random.", "renpy.random.")
for method in ("randint", "random", "choice", "shuffle", "uniform")}
CALLS |= {"math." + method for method in ("floor", "ceil", "sqrt", "pow")}
CALLS |= {"renpy." + method for method in ("play", "notify", "jump", "call", "input", "has_label",
"seen_label", "showing", "get_attributes", "full_restart", "quit", "version")}
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("play", "queue", "stop", "get_playing", "is_playing")}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
def dotted(value):
if value.get("k") == "name":
return value["id"]
if value.get("k") == "attr":
return dotted(value["o"]) + "." + value["a"]
return ""
def visit(value, location):
if isinstance(value, list):
for item in value:
visit(item, location)
elif isinstance(value, dict):
if value.get("k") == "call":
function = value.get("f", {})
name = dotted(function)
if name not in CALLS and not (function.get("k") == "attr" and
function.get("a") in METHODS and not name.startswith(("renpy.", "math.", "random."))):
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
if value.get("op") in {"jump", "call"} and value.get("target"):
if value["target"] not in labels:
data.warn(f"Не найдена цель перехода: {value['target']}", location)
for child in value.values():
visit(child, location)
from app.converter.pipeline import anchors
labels = {label["name"] for module in data.modules.values() for label in module}
labels |= {name for module in data.modules.values() for name in anchors(module)}
for path, module in data.modules.items():
visit(module, path)
visit(data.defaults, "default")
visit(data.init_code, "init")
visit(data.images, "image")
+7 -5
View File
@@ -8,6 +8,7 @@
import hashlib import hashlib
import re import re
from uuid import uuid4
from collections import Counter, defaultdict from collections import Counter, defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -90,20 +91,21 @@ def assign_ids(modules: dict[str, list[dict[str, Any]]]) -> dict[tuple[str, int]
by_line: dict[tuple[str, int], dict] = {} by_line: dict[tuple[str, int], dict] = {}
used: Counter = Counter() used: Counter = Counter()
for file, labels in modules.items(): for file, labels in modules.items():
n = 0
for label in labels: for label in labels:
label.setdefault("id", str(uuid4()))
group = label["name"] group = label["name"]
for node in walk(label["body"]): for node in walk(label["body"]):
n += 1 node.setdefault("id", str(uuid4()))
node["id"] = short_hash(f"{file}:{n}") for branch in node.get("branches", []) + node.get("items", []):
branch.setdefault("id", str(uuid4()))
if node.get("op") == "label": if node.get("op") == "label":
group = node["name"] group = node["name"]
if node.get("op") != "say": if node.get("op") not in {"say", "nani_add_choice"}:
continue continue
# Ren'Py style: <label>_<hash of the line>, de-duplicated # Ren'Py style: <label>_<hash of the line>, de-duplicated
base = f"{group}_{short_hash((node.get('who') or '') + chr(0) + node['what'])}" base = f"{group}_{short_hash((node.get('who') or '') + chr(0) + node['what'])}"
used[base] += 1 used[base] += 1
node["tid"] = base if used[base] == 1 else f"{base}_{used[base] - 1}" node.setdefault("tid", base if used[base] == 1 else f"{base}_{used[base] - 1}")
node["_group"] = group node["_group"] = group
if "_ln" in node: if "_ln" in node:
by_line[(file, node["_ln"])] = node by_line[(file, node["_ln"])] = node
+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
+93
View File
@@ -0,0 +1,93 @@
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import re
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8,
"<=": 8, ">=": 8, "<<": 9, ">>": 9, "+": 10, "-": 10,
"*": 11, "/": 11, "%": 11}
FUNCTIONS = {"Random", "random", "Rand", "GetRandValue", "Min", "Max", "Abs", "Round", "Pow"}
def parse(source: str, functions: set[str] | None = None) -> list:
source = source.strip().removesuffix(";").strip()
tokens, offset = [], 0
while offset < len(source):
match = TOKEN.match(source, offset)
if not match:
raise ValueError(f"Не поддерживается выражение Naninovel: {source[offset:offset + 60]}")
tokens.append((next(i for i, v in enumerate(match.groups()) if v is not None),
next(v for v in match.groups() if v is not None)))
offset = match.end()
position = 0
allowed = FUNCTIONS if functions is None else functions
def peek():
return tokens[position][1] if position < len(tokens) else ""
def take(expected=None):
nonlocal position
if position >= len(tokens) or (expected is not None and peek() != expected):
raise ValueError(f"Ожидалось {expected or 'выражение'}: {source}")
token = tokens[position]
position += 1
return token
def expression(minimum=1):
kind, token = take()
if token in {"-", "+", "!", "not", "~"}:
left = ["unary", token, expression(12)]
elif token == "(":
left = expression()
take(")")
elif kind == 0:
left = ["const", float(token) if any(c in token for c in ".eE") else int(token)]
elif kind == 2:
# Decode only conventional escapes; preserve all Unicode prose.
left = ["const", re.sub(r"\\([\\\"'nrt])", lambda m:
{"n": "\n", "r": "\r", "t": "\t"}.get(m[1], m[1]), token[1:-1])]
elif kind == 1:
if token.lower() in {"true", "false", "null"}:
left = ["const", {"true": True, "false": False, "null": None}[token.lower()]]
elif peek() == "(":
if token not in allowed:
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
take("(")
args = []
if peek() != ")":
while True:
args.append(expression())
if peek() != ",":
break
take(",")
take(")")
left = ["call", token, args]
else:
left = ["name", token]
else:
raise ValueError(f"Неожиданный токен {token}: {source}")
while peek() in PRECEDENCE and PRECEDENCE[peek()] >= minimum:
op = take()[1]
left = ["bin", op, left, expression(PRECEDENCE[op] + 1)]
if minimum <= 1 and peek() == "?":
take("?")
yes = expression()
take(":")
left = ["if", left, yes, expression()]
return left
ast = expression()
if position != len(tokens):
raise ValueError(f"Лишние токены в выражении Naninovel: {source}")
return ast
def expr(source: str) -> dict:
return {"k": "nani", "ast": parse(source)}
def assignment(source: str) -> dict:
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*=(?!=)(.*?)\s*;?\s*", source, re.S)
if not match:
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": expr(match[2])}
+148
View File
@@ -0,0 +1,148 @@
"""Recover Naninovel data from Unity builds without executing their managed code."""
from dataclasses import dataclass, field
from pathlib import Path
import hashlib
import re
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.unity_bundle import unpack
@dataclass
class Package:
scripts: dict = field(default_factory=dict)
texts: dict = field(default_factory=dict)
resources: list = field(default_factory=list)
issues: list = field(default_factory=list)
def simplify(value):
if isinstance(value, dict):
if "hasValue" in value and "value" in value:
dynamic = value.get("dynamicValue", {})
if dynamic.get("ValueText"):
return {"template": dynamic["ValueText"]}
return simplify(value["value"]) if value["hasValue"] else None
return {k: simplify(v) for k, v in value.items()}
if isinstance(value, list):
return [simplify(v) for v in value]
return value
def decode_script(tree: dict) -> dict:
references = tree["references"]["records"]
def deref(ref):
index = ref["id"]
if not isinstance(index, int) or not 0 <= index < len(references):
raise ValueError("Некорректная ссылка в сценарии Naninovel")
return references[index]
lines = []
for pointer in tree["lines"]:
record = deref(pointer)
kind, body = record["type"]["class"], record["data"]
commands = []
if kind == "CommandScriptLine":
commands = [deref(body["command"])]
elif kind == "GenericTextScriptLine":
commands = [deref(ref) for ref in body["inlinedCommands"]]
elif kind not in {"LabelScriptLine", "CommentScriptLine", "EmptyScriptLine"}:
raise ValueError(f"Не поддерживается тип строки Naninovel: {kind}")
lines.append({"line": body["lineIndex"], "label": body.get("labelText"),
"hash": body.get("lineHash", ""), "commands": [
{"op": command["type"]["class"], "args": {
k: value for k, v in command["data"].items()
if k != "playbackSpot" and (value := simplify(v)) is not None}}
for command in commands]})
return {"name": tree["m_Name"], "lines": lines}
def text_records(name: str, text: str, origin: str) -> dict:
"""Addressable entries; delimiters and keys remain outside translatable prose."""
records, keys = {}, set()
for number, line in enumerate(text.splitlines(), 1):
if not line.strip() or line.lstrip().startswith((";", "#", "//")):
continue
if name == "_synonyms":
key, sep, value = line.partition("=")
if not sep:
raise ValueError(f"Некорректная таблица синонимов: {origin}:{number}")
values, kind = value.split(","), "synonym"
elif name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}:
key, sep, value = line.partition(":")
if not sep:
# Naninovel managed-text resources can contain non-entry lines.
# They are not dictionary values and must not become translations.
continue
values, kind = value.split("|") if name == "Tips" else [value], "dictionary"
else:
key, values, kind = str(number), [line], "template"
if key.strip() in keys:
raise ValueError(f"Повторяющийся ключ локализации: {origin}:{key.strip()}")
keys.add(key.strip())
for index, value in enumerate(values):
if not value.strip():
continue
address = f"{origin}:{key.strip()}:{index}"
tid = "nani-resource-" + hashlib.sha256(address.encode()).hexdigest()[:24]
records[tid] = {"text": value, "kind": kind, "origin": origin, "key": key.strip(),
"part": index, "line": number,
"delimiters": ",=\r\n" if kind == "synonym" else
"|\r\n" if name == "Tips" else "\r\n"}
return records
def read_package(root: Path, work: Path, *, media: bool = False) -> Package:
import UnityPy
from app.converter.unity_scripts import ScriptCodec, script_objects
package = Package()
data_roots = [p for p in root.iterdir() if p.is_dir() and p.name.lower().endswith("_data")]
if len(data_roots) != 1:
raise CompatibilityError("naninovel", [Issue("unity_layout", "Неоднозначный каталог Unity Data")])
data = data_roots[0]
sources = sorted(data.glob("*.assets")) + sorted(data.glob("*.unity3d"))
sources += sorted((data / "StreamingAssets").rglob("*.bundle"))
codec = None
for number, source in enumerate(sources):
try:
with source.open("rb") as handle:
bundled = handle.read(8).startswith(b"UnityFS")
paths = unpack(source, work / f"bundle-{number}") if bundled else [source]
for path in paths:
if path.name.endswith((".resource", ".resS")):
continue
environment = UnityPy.load(str(path))
for stream in data.glob("*.resource"):
environment.load_file(str(stream))
for obj in script_objects(environment):
if codec is None:
codec = ScriptCodec(root, str(obj.assets_file.unity_version))
with codec.active():
decoded = decode_script(codec.read(obj))
name = decoded["name"]
previous = package.scripts.get(name)
if previous is not None and previous != decoded:
package.issues.append(Issue("conflicting_script", "Различающиеся копии сценария", name))
else:
package.scripts[name] = decoded
for obj in environment.objects:
if obj.type.name == "TextAsset":
asset = obj.read()
if asset.m_Name in {"DefaultUI", "Tips", "CharacterNames", "Inventory"}:
origin = f"{source.relative_to(root).as_posix()}/{obj.path_id}/{asset.m_Name}"
package.texts.update(text_records(asset.m_Name, asset.m_Script, origin))
elif media and obj.type.name in {"Texture2D", "AudioClip", "Font", "VideoClip"}:
package.resources.append((source, obj))
except CompatibilityError:
raise
except Exception as exc:
package.issues.append(Issue("unity_decode", f"Не удалось прочитать ресурсы: {exc}",
source.relative_to(root).as_posix()))
for source in sorted((data / "StreamingAssets" / "text").glob("*.txt")):
package.texts.update(text_records(source.stem, source.read_text(encoding="utf-8-sig"),
source.relative_to(root).as_posix()))
if not package.scripts:
package.issues.append(Issue("missing_scripts", "Не найдены читаемые сценарии Naninovel"))
return package
+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)
+26
View File
@@ -19,6 +19,7 @@ from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing from app.converter.rpyc import decompile_missing
from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
FORMAT = "pgvn/1" FORMAT = "pgvn/1"
@@ -46,12 +47,26 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
data = ScriptData() data = ScriptData()
if archive.is_dir(): if archive.is_dir():
detection = detect(archive)
if detection.engine == "naninovel":
from app.converter.naninovel import convert_naninovel
return convert_naninovel(detection.root, work, progress)
# Inspect scripts before copying/converting multi-gigabyte media collections.
from app.converter.preflight import inspect_directory
inspect_directory(detection.root)
progress("Копирование файлов игры") progress("Копирование файлов игры")
game_dir = src_root / "game" game_dir = src_root / "game"
shutil.copytree(find_game_dir(archive), game_dir) shutil.copytree(find_game_dir(archive), game_dir)
else: else:
from app.converter.preflight import inspect_archive
progress("Проверка движка и сценариев до распаковки медиа")
inspect_archive(archive, filename)
progress("Распаковка архива") progress("Распаковка архива")
extract(archive, filename, src_root) extract(archive, filename, src_root)
detection = detect(src_root)
if detection.engine == "naninovel":
from app.converter.naninovel import convert_naninovel
return convert_naninovel(detection.root, work, progress)
game_dir = find_game_dir(src_root) game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa")) rpas = sorted(game_dir.rglob("*.rpa"))
@@ -79,6 +94,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
data.warn("Слишком глубокая вложенность", rel) data.warn("Слишком глубокая вложенность", rel)
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels): if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
raise RuntimeError("В скриптах не найдена метка start — нечего запускать") raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
audit_ir(data)
require_supported_scripts(data)
files: dict[str, dict[str, Any]] = {} files: dict[str, dict[str, Any]] = {}
aliases: dict[str, str] = {} aliases: dict[str, str] = {}
@@ -112,6 +129,10 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
aliases[rel.lower()] = logical aliases[rel.lower()] = logical
counts[assets.kind_of(p)] += 1 counts[assets.kind_of(p)] += 1
if failed:
raise CompatibilityError("renpy", [Issue("media_conversion", "Не удалось конвертировать обязательное медиа", name)
for name in failed])
progress("Сборка формата PGVN") progress("Сборка формата PGVN")
_auto_images(data, aliases) _auto_images(data, aliases)
missing: set[str] = set() missing: set[str] = set()
@@ -176,6 +197,11 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
total = sum(f["size"] for f in files.values()) total = sum(f["size"] for f in files.values())
report = { report = {
"engine": "renpy",
"compatible": True,
"converterVersion": "pgvn-tools/2",
"unrpycRevision": (get_settings().unrpyc_path.parent / "REVISION").read_text().strip()
if (get_settings().unrpyc_path.parent / "REVISION").is_file() else "unavailable",
"format": FORMAT, "format": FORMAT,
"durationSec": round(time.monotonic() - started, 1), "durationSec": round(time.monotonic() - started, 1),
"scripts": len(rpy_files), "scripts": len(rpy_files),
+77
View File
@@ -0,0 +1,77 @@
"""Read-only compatibility check, shared by uploads, CLI and the converter."""
from pathlib import Path
import tempfile
import zipfile
import shutil
import subprocess
from app.converter.archive import extract
from app.converter.compatibility import CompatibilityError, Issue, detect
def probe_archive(source: Path, filename: str) -> dict:
"""Cheap distribution check before queueing: list entries, never extract media."""
if zipfile.is_zipfile(source):
with zipfile.ZipFile(source) as archive:
entries = [entry.filename.replace("\\", "/") for entry in archive.infolist()]
else:
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
from app.converter.archive import ArchiveError
raise ArchiveError("Для проверки этого архива нужен 7-Zip")
process = subprocess.run([seven, "l", "-slt", "-ba", "-sccUTF-8", str(source)],
capture_output=True, encoding="utf-8", errors="replace", timeout=120)
if process.returncode:
from app.converter.archive import ArchiveError
raise ArchiveError("Не удалось прочитать архив: " + (process.stderr or process.stdout)[-500:])
entries = [line[7:].replace("\\", "/") for line in process.stdout.splitlines() if line.startswith("Path = ")]
from app.converter.compatibility import IGNORED
entries = [entry.lower() for entry in entries
if not any(part.lower() in IGNORED for part in Path(entry).parts)]
engines = set()
if any("naninovel" in entry.rsplit("/", 1)[-1] and entry.endswith(".dll") for entry in entries):
engines.add("naninovel")
elif any("_data/managed/" in entry or "_data/il2cpp_data/" in entry or entry.endswith("_data/data.unity3d") for entry in entries):
engines.add("unity")
if any(entry.rsplit("/", 1)[-1] == "data.win" for entry in entries):
engines.add("gamemaker")
if any(entry.endswith((".pck", "/project.godot")) or entry == "project.godot" for entry in entries):
engines.add("godot")
if any(entry.endswith((".rgss3a", ".rgss2a", ".rgssad", "/rpg_core.js", "/rmmz_core.js")) for entry in entries):
engines.add("rpgmaker")
if any(entry.endswith((".rpy", ".rpyc", ".rpa")) and "renpy/" not in entry for entry in entries):
engines.add("renpy")
if len(engines) != 1:
raise CompatibilityError("ambiguous" if engines else "unknown", [Issue(
"multiple_engines" if engines else "unknown_engine",
"В архиве несколько движков: " + ", ".join(sorted(engines)) if engines else
"Движок не распознан; нужен полный дистрибутив игры")])
engine = engines.pop()
if engine not in {"renpy", "naninovel"}:
raise CompatibilityError(engine, [Issue("unsupported_engine", f"Движок {engine} пока не поддерживается")])
return {"engine": engine, "scriptsChecked": False}
def inspect_directory(root: Path, *, work: Path | None = None) -> dict:
detection = detect(root)
if detection.engine == "renpy":
from app.converter.analyze import analyze
report = analyze(detection.root)
issues = [Issue(**issue) for issue in report.pop("issues", [])]
if not report["hasStart"]:
issues.append(Issue("missing_entry", "Не найдена точка входа start"))
if issues:
raise CompatibilityError("renpy", issues)
return {"engine": "renpy", "compatible": True, **report}
from app.converter.naninovel import inspect_naninovel
return inspect_naninovel(detection.root, work=work)
def inspect_archive(source: Path, filename: str) -> dict:
if source.is_dir():
return inspect_directory(source)
with tempfile.TemporaryDirectory(prefix="pgames-preflight-") as directory:
root = Path(directory)
probe = probe_archive(source, filename)
extract(source, filename, root, only_scripts=probe["engine"] == "renpy")
return inspect_directory(root, work=root / "_inspection")
+6 -2
View File
@@ -63,11 +63,14 @@ class ScriptData:
config: dict[str, Any] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict)
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
warnings: Counter = field(default_factory=Counter) warnings: Counter = field(default_factory=Counter)
blockers: Counter = field(default_factory=Counter)
warning_examples: dict[str, str] = field(default_factory=dict) warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter) stats: Counter = field(default_factory=Counter)
def warn(self, kind: str, where: str = "") -> None: def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
self.warnings[kind] += 1 self.warnings[kind] += 1
if blocking:
self.blockers[kind] += 1
if where and kind not in self.warning_examples: if where and kind not in self.warning_examples:
self.warning_examples[kind] = where self.warning_examples[kind] = where
@@ -234,7 +237,8 @@ class RpyParser:
return [] return []
if word in SKIP_BLOCKS: if word in SKIP_BLOCKS:
if word not in ("style", "translate"): if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line)) self.d.warn(f"Не поддерживается: {word}", self.where(line),
blocking=not (top_level and word == "screen"))
return [] return []
say = self.say(line) say = self.say(line)
if say is not None: if say is not None:
+65
View File
@@ -0,0 +1,65 @@
"""Stream UnityFS nodes to a private workspace; do not load media bundles into RAM."""
from pathlib import Path
def unpack(source: Path, target: Path) -> list[Path]:
from UnityPy.streams import EndianBinaryReader
from UnityPy.helpers.CompressionHelper import DECOMPRESSION_MAP
target.mkdir(parents=True, exist_ok=True)
with source.open("rb") as stream:
reader = EndianBinaryReader(stream)
if reader.read_string_to_null() != "UnityFS":
raise ValueError(f"Неподдерживаемый Unity-контейнер: {source.name}")
version = reader.read_u_int()
reader.read_string_to_null()
reader.read_string_to_null()
size = reader.read_long()
packed, raw, flags = reader.read_u_int(), reader.read_u_int(), reader.read_u_int()
if version not in {6, 7, 8} or flags & ~0x3ff:
raise ValueError(f"Неподдерживаемый UnityFS: версия {version}, flags={flags}")
if version >= 7:
reader.align_stream(16)
start = reader.Position
if flags & 0x80:
reader.Position = size - packed
info = EndianBinaryReader(DECOMPRESSION_MAP[flags & 63](reader.read_bytes(packed), raw))
if flags & 0x80:
reader.Position = start
info.read_bytes(16)
count = info.read_int()
if not 0 <= count <= 1_000_000:
raise ValueError("Некорректное количество блоков UnityFS")
blocks = [(info.read_u_int(), info.read_u_int(), info.read_u_short()) for _ in range(count)]
count = info.read_int()
if not 0 <= count <= 100_000:
raise ValueError("Некорректное количество файлов UnityFS")
nodes = [(info.read_long(), info.read_long(), info.read_u_int(), info.read_string_to_null())
for _ in range(count)]
if flags & 0x200:
reader.align_stream(16)
handles, paths = [], []
total = sum(b[0] for b in blocks)
try:
for offset, length, _, name in nodes:
path = (target / name).resolve()
if (not path.is_relative_to(target.resolve()) or path in paths or
offset < 0 or length < 0 or offset + length > total):
raise ValueError(f"Некорректный файл UnityFS: {name}")
path.parent.mkdir(parents=True, exist_ok=True)
handles.append((offset, length, path.open("wb")))
paths.append(path)
position = 0
for raw_size, packed_size, block_flags in blocks:
chunk = DECOMPRESSION_MAP[block_flags & 63](reader.read_bytes(packed_size), raw_size)
if len(chunk) != raw_size:
raise ValueError("Повреждённый блок UnityFS")
for offset, length, handle in handles:
left, right = max(position, offset), min(position + raw_size, offset + length)
if left < right:
handle.write(chunk[left - position:right - position])
position += raw_size
finally:
for _, _, handle in handles:
handle.close()
return paths
+128
View File
@@ -0,0 +1,128 @@
"""Strict codec for Unity 2019 managed-reference scenario objects.
Schemas come from the game's own assemblies. The old version-1 registry uses
implicit integer IDs and a Terminus record, rather than the modern array format.
Use in an isolated asset-processing process; UnityPy's codec hooks are global.
"""
from contextlib import contextmanager
class ScriptCodec:
def __init__(self, game, version="2019.4.22f1"):
from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator
self.generator = TypeTreeGenerator(version)
self.generator.load_local_game(str(game))
self.cache = {}
self.templates = {}
def node(self, assembly, fullname, reference=False):
from UnityPy.helpers import TypeTreeHelper as helper
from UnityPy.helpers.TypeTreeNode import TypeTreeNode
key = (assembly, fullname, reference)
if key in self.cache:
return self.cache[key]
source = self.generator.get_nodes_up(assembly, fullname)
# The generator calls List<string> "string"; the generic reader would
# mistake its count for a scalar string length. Normalize array nodes.
def clone(n):
values = n.to_dict()
values["m_Children"] = [clone(c) for c in n.m_Children]
return TypeTreeNode(**values)
node = clone(source)
for child in node.traverse():
if (child.m_Children and child.m_Children[0].m_Type == "Array"
and child.m_Type in helper.FUNCTION_READ_MAP
and child.m_Children[0].m_Children[1].m_Type != "char"):
child.m_Type = "vector"
if reference:
# Generator assumes MonoBehaviour even for plain serializable classes.
children = [c for c in node.m_Children if c.m_Name not in
{"m_GameObject", "m_Enabled", "m_Script", "m_Name", "references"}]
node = TypeTreeNode(0, node.m_Type, node.m_Name, -1, 0, m_Children=children)
self.cache[key] = node
return node
def ref_node(self, typ):
if not typ["class"]:
return None
if not typ["asm"] or not all(ord(c) >= 32 for c in typ["class"] + typ["ns"] + typ["asm"]):
raise ValueError("Invalid managed-reference type; refusing to guess its layout")
key = (typ["asm"].removesuffix(".dll"), typ["ns"], typ["class"])
if key in self.templates:
return self.templates[key]
return self.node(typ["asm"], ".".join(filter(None, [typ["ns"], typ["class"]])), True)
@contextmanager
def active(self):
from UnityPy.helpers import TypeTreeHelper as helper
old_read, old_write = helper.read_value, helper.write_value
old_boost, old_resolve = helper.read_typetree_boost, helper.get_ref_type_node
def read(node, reader, config):
if node.m_Type != "ManagedReferencesRegistry":
return old_read(node, reader, config)
version = reader.read_int()
if version != 1:
raise ValueError(f"Unsupported managed-reference registry: {version}")
type_node = node.m_Children[1].m_Children[0]
records = []
while len(records) < 1_000_000:
typ = old_read(type_node, reader, config)
if typ == {"class": "Terminus", "ns": "UnityEngine.DMAT", "asm": "FAKE_ASM"}:
return {"version": version, "records": records, "terminus": typ}
schema = self.ref_node(typ)
records.append({"type": typ, "data": read(schema, reader, config) if schema else {}})
raise ValueError("Unterminated managed-reference registry")
def write(value, node, writer, config):
if node.m_Type != "ManagedReferencesRegistry":
return old_write(value, node, writer, config)
if value["version"] != 1:
raise ValueError("Unsupported registry version")
writer.write_int(1)
type_node = node.m_Children[1].m_Children[0]
for ref in value["records"]:
old_write(ref["type"], type_node, writer, config)
schema = self.ref_node(ref["type"])
if schema:
write(ref["data"], schema, writer, config)
old_write(value["terminus"], type_node, writer, config)
helper.read_typetree_boost = None
helper.get_ref_type_node = lambda ref, _: self.ref_node(ref["type"])
helper.read_value, helper.write_value = read, write
try:
yield self
finally:
helper.read_value, helper.write_value = old_read, old_write
helper.read_typetree_boost, helper.get_ref_type_node = old_boost, old_resolve
def read(self, obj):
from UnityPy.streams import EndianBinaryWriter
from UnityPy.helpers import TypeTreeHelper as helper
# Bundles can retain exact schemas which differ from generated metadata
# (notably editor-compiled Init). Prefer those schemas when available.
self.templates = {(ref.m_AssemblyName.removesuffix(".dll"), ref.m_NameSpace, ref.m_ClassName): ref.node
for ref in obj.assets_file.ref_types if ref.node is not None}
node = obj.serialized_type.node
if node is None:
node = self.node("Elringus.Naninovel.Runtime", "Naninovel.Script")
tree = obj.read_typetree(nodes=node)
writer = EndianBinaryWriter(endian=obj.reader.endian)
helper.write_typetree(tree, node, writer, obj.assets_file)
if writer.bytes != obj.get_raw_data():
raise ValueError(f"Script {obj.path_id}: binary round-trip mismatch")
return tree
def script_objects(environment):
for obj in environment.objects:
if obj.type.name != "MonoBehaviour":
continue
try:
head = obj.parse_monobehaviour_head()
script = head.m_Script.read()
except (ValueError, AttributeError, FileNotFoundError):
continue
if script.m_Namespace == "Naninovel" and script.m_ClassName == "Script":
yield obj
+2
View File
@@ -14,5 +14,7 @@ class Base(DeclarativeBase):
async def get_session() -> AsyncIterator[AsyncSession]: async def get_session() -> AsyncIterator[AsyncSession]:
from app.maintenance import storage_access
async with storage_access():
async with SessionLocal() as session: async with SessionLocal() as session:
yield session yield session
+2 -1
View File
@@ -28,10 +28,11 @@ async def session_user(
return await _load_user(db, token) return await _load_user(db, token)
async def current_user(user: Annotated[User, Depends(session_user)]) -> User: async def current_user(user: Annotated[User, Depends(session_user)], db: DB) -> User:
"""Authenticated user who has finished the forced credential change.""" """Authenticated user who has finished the forced credential change."""
if user.must_change_password or user.must_change_username: if user.must_change_password or user.must_change_username:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Необходимо сменить учётные данные") raise HTTPException(status.HTTP_403_FORBIDDEN, "Необходимо сменить учётные данные")
db.info["user_id"] = user.id
return user return user
+2
View File
@@ -39,6 +39,8 @@ def fetch(url: str) -> str:
except F95Error: except F95Error:
raise raise
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code >= 500:
raise F95Error(f"F95zone сейчас недоступен (ответил {e.code}), попробуйте позже")
raise F95Error(f"F95zone ответил {e.code} (тема недоступна или сайт блокирует запрос)") raise F95Error(f"F95zone ответил {e.code} (тема недоступна или сайт блокирует запрос)")
except OSError: except OSError:
raise F95Error("Не удалось связаться с F95zone") raise F95Error("Не удалось связаться с F95zone")
+99
View File
@@ -0,0 +1,99 @@
"""Reachability GC. Dry-run by default; exclusive lease covers mark AND sweep.
python -m app.gc [--execute] [--interval 86400]
"""
import argparse
import asyncio
import json
from sqlalchemy import delete, select
from app.blobs import BLOB_ID_RE, blob_path
from app.config import get_settings
from app.db import SessionLocal
from app.maintenance import storage_access
from app.models import Game, GameVersion, Revision, VersionHistory
def hashes(manifest):
return {entry["hash"] for entry in (manifest or {}).get("files", {}).values()}
def reachable_revisions(graph, roots):
live, pending = set(), list(roots)
while pending:
key = pending.pop()
if key in live or key not in graph:
continue
live.add(key)
pending.extend(graph[key].parents)
return live
def candidates(root, live):
root = root.resolve()
result = []
for path in root.glob("*/*/*"):
if not BLOB_ID_RE.fullmatch(path.name) or path.name in live:
continue
# Never follow links out of the blob store (including linked directories).
if path.is_symlink() or path.parent.is_symlink() or path.parent.parent.is_symlink():
continue
resolved = path.resolve()
if resolved.is_relative_to(root) and path.is_file() and path == blob_path(path.name):
result.append(path)
return result
async def collect(execute=False):
async with storage_access(exclusive=True):
async with SessionLocal() as db:
versions = list((await db.scalars(select(GameVersion))).all())
graph = {r.id: r for r in (await db.scalars(select(Revision))).all()}
roots = {rid for v in versions for rid in (v.revision_id, v.base_revision_id) if rid}
if roots - set(graph):
raise RuntimeError("Missing root revisions; GC aborted")
live_revisions = reachable_revisions(graph, roots)
if any(p not in graph for key in live_revisions for p in graph[key].parents):
raise RuntimeError("Incomplete revision graph; GC aborted")
live = set()
for v in versions:
live.update(hashes(v.manifest))
for key in live_revisions:
live.update(hashes(graph[key].manifest))
for manifest in (await db.scalars(select(VersionHistory.manifest))).all():
live.update(hashes(manifest))
live.update(h for h in (await db.scalars(select(Game.cover_hash))).all() if h)
for key in live:
if not BLOB_ID_RE.fullmatch(key) or not blob_path(key).is_file():
raise RuntimeError(f"Missing/invalid referenced blob {key}; GC aborted")
garbage = candidates(get_settings().blobs_dir, live)
orphan_revisions = set(graph) - live_revisions
report = {"execute": execute, "files": len(garbage),
"bytes": sum(p.stat().st_size for p in garbage),
"orphanRevisions": len(orphan_revisions), "retainedBlobs": len(live)}
if execute:
if orphan_revisions:
await db.execute(delete(Revision).where(Revision.id.in_(orphan_revisions)))
await db.commit()
for path in garbage:
path.unlink(missing_ok=True)
return report
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--execute", action="store_true", help="Delete unreachable files and orphan revision rows")
parser.add_argument("--interval", type=int, default=0, help="Repeat interval in seconds (0: one run)")
args = parser.parse_args()
if args.interval and args.interval < 60:
parser.error("interval must be at least 60 seconds")
while True:
print(json.dumps(await collect(args.execute)), flush=True)
if not args.interval:
break
await asyncio.sleep(args.interval)
if __name__ == "__main__":
asyncio.run(main())
+100
View File
@@ -0,0 +1,100 @@
"""Check/import a local collection. Rejected games never get queued.
python -m app.import_library /games --check
python -m app.import_library /games --import --owner admin
"""
import argparse
import asyncio
import json
from pathlib import Path
import tempfile
import sys
from app.converter.archive import ArchiveError, extract
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.preflight import inspect_archive, inspect_directory
ARCHIVES = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def entries(root: Path):
"""An unpacked copy takes precedence over an archive with the identical name."""
directories = {p.name.casefold() for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")}
for source in sorted(root.iterdir(), key=lambda p: p.name.casefold()):
if source.name.startswith(".") or source.is_symlink():
continue
suffix = next((ext for ext in ARCHIVES if source.name.lower().endswith(ext)), None)
if source.is_file() and suffix and source.name[:-len(suffix)].casefold() in directories:
continue
yield source
def check(source: Path) -> dict:
try:
if source.is_dir():
return inspect_directory(source)
if not source.name.lower().endswith(ARCHIVES):
raise CompatibilityError("unknown", [Issue("not_distribution",
"Нужна папка или архив полного дистрибутива; отдельные EXE и RPA не импортируются")])
return inspect_archive(source, source.name)
except CompatibilityError as exc:
return exc.report()
except Exception as exc:
return {"compatible": False, "engine": "unknown", "error": str(exc),
"issues": [{"code": "input_error", "message": str(exc), "location": source.name, "count": 1}]}
async def import_one(source: Path, report: dict, username: str | None) -> dict:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Game, GameVersion, Role, User
from app.worker import convert
async with SessionLocal() as db:
stmt = select(User).join(Role).order_by(User.id)
stmt = stmt.where(User.username == username) if username else stmt.where(Role.name == "admin")
owner = await db.scalar(stmt.limit(1))
if owner is None:
raise ValueError("Пользователь для импорта не найден")
game = Game(title=source.name, engine=report["engine"], created_by_id=owner.id)
db.add(game)
await db.flush()
version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал",
upload_name=source.name, status="importing", report=report)
db.add(version)
await db.commit()
game_id, version_id = game.id, version.id
ok = await convert(version_id, source, source.name)
return {"gameId": game_id, "versionId": version_id, "status": "ready" if ok else "failed"}
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", type=Path)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true", help="Только проверка; без изменения библиотеки")
mode.add_argument("--import", dest="import_games", action="store_true", help="Импортировать совместимые игры")
parser.add_argument("--owner")
parser.add_argument("--output", type=Path, help="Записать полный JSON-отчёт")
args = parser.parse_args()
if not args.root.is_dir():
parser.error("root должен быть каталогом коллекции")
results = []
for source in entries(args.root):
print(json.dumps({"game": source.name, "status": "checking"}, ensure_ascii=False), flush=True)
report = await asyncio.to_thread(check, source)
result = {"game": source.name, **report}
if args.import_games and report.get("compatible"):
result.update(await import_one(source, report, args.owner))
results.append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
if any(not result.get("compatible") or result.get("status") == "failed" for result in results):
raise SystemExit(2)
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
asyncio.run(main())
+3 -13
View File
@@ -1,14 +1,12 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from fastapi.responses import FileResponse
from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap from app.bootstrap import bootstrap
from app.config import get_settings from app.config import get_settings
from app.db import SessionLocal from app.db import SessionLocal
from app.routers import auth, games, i18n, metadata, roles, users, versions from app.routers import auth, games, i18n, media, metadata, mods, roles, users, versions
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -24,18 +22,10 @@ async def lifespan(_: FastAPI):
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs", app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json") openapi_url="/api/openapi.json")
for r in (auth, users, roles, games, versions, i18n, metadata): for r in (auth, users, roles, games, versions, i18n, metadata, media, mods):
app.include_router(r.router) app.include_router(r.router)
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/blobs/{blob_id}")
async def blob(blob_id: str) -> FileResponse:
"""Fallback for running without nginx; in compose nginx serves /blobs itself."""
if not BLOB_ID_RE.match(blob_id) or not (path := blob_path(blob_id)).is_file():
raise HTTPException(404)
return FileResponse(path, headers={"Cache-Control": "public, max-age=31536000, immutable"})
+49
View File
@@ -0,0 +1,49 @@
"""Shared writer leases and exclusive GC on the common data volume."""
import asyncio
import os
from contextlib import asynccontextmanager
from app.config import get_settings
def acquire(exclusive=False):
root = get_settings().data_dir
root.mkdir(parents=True, exist_ok=True)
handle = (root / "maintenance.lock").open("a+b")
try:
if os.name == "nt":
import msvcrt
if handle.tell() == 0:
handle.write(b"0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
return handle
except BaseException:
handle.close()
raise
def release(handle):
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
handle.close()
@asynccontextmanager
async def storage_access(exclusive=False):
task = asyncio.create_task(asyncio.to_thread(acquire, exclusive))
try:
handle = await asyncio.shield(task)
except asyncio.CancelledError:
task.add_done_callback(lambda done: release(done.result()) if not done.cancelled() and not done.exception() else None)
raise
try:
yield
finally:
release(handle)
+17
View File
@@ -83,6 +83,8 @@ class GameVersion(Base):
progress: Mapped[str] = mapped_column(String(255), default="") progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255)) upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
revision_id: Mapped[str | None] = mapped_column(String(36))
base_revision_id: Mapped[str | None] = mapped_column(String(36))
report: Mapped[dict[str, Any] | None] = mapped_column(JSONB) report: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -103,6 +105,18 @@ class VersionHistory(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Revision(Base):
"""Immutable snapshots retained independently of deletable version cards."""
__tablename__ = "revisions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
parents: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
manifest: Mapped[dict[str, Any]] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Save(Base): class Save(Base):
__tablename__ = "saves" __tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
@@ -116,3 +130,6 @@ class Save(Base):
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
from app import revisions as _revisions # noqa: E402,F401
+337
View File
@@ -0,0 +1,337 @@
"""Validated PGVN editing and deterministic three-way merges (no source execution)."""
import copy
import difflib
import json
from uuid import uuid4, uuid5, NAMESPACE_URL
from app.blobs import BLOB_ID_RE
from app.pgvn import expression, statements
from app.converter.locales import walk
from app.converter.pipeline import anchors
from app.translation import file_json, put_json
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
MISSING = object()
def document(manifest):
game = copy.deepcopy(file_json(manifest, "game.json"))
return {"game": game, "scripts": {p: copy.deepcopy(file_json(manifest, p)) for p in game["scripts"]}}
def check_path(path):
if not isinstance(path, str) or not path or "\\" in path or path.startswith("/") or any(p in ("", ".", "..") for p in path.split("/")):
raise ValueError("Некорректный путь файла")
return path
def validate(doc, manifest):
"""Validate the whole playable graph before writing any script blobs."""
if not isinstance(doc, dict) or not isinstance(doc.get("game"), dict) or not isinstance(doc.get("scripts"), dict):
raise ValueError("Нужны game и scripts")
game, scripts = doc["game"], doc["scripts"]
paths = game.get("scripts")
if game.get("format") != "pgvn/1" or not isinstance(paths, list) or any(not isinstance(p, str) for p in paths) or len(set(paths)) != len(paths) or set(paths) != set(scripts):
raise ValueError("Список модулей не совпадает с game.scripts")
resolution = game.get("resolution")
if not isinstance(resolution, list) or len(resolution) != 2 or any(type(n) is not int or not 1 <= n <= 32768 for n in resolution):
raise ValueError("Некорректное разрешение игры")
for key in ("characters", "images", "transforms", "transitions", "audio", "files", "ui"):
if not isinstance(game.get(key), dict):
raise ValueError(f"Нужен объект game.{key}")
if not isinstance(game.get("defaults"), list) or not isinstance(game.get("init"), list):
raise ValueError("Некорректные defaults/init")
statements(game["init"])
for item in game["defaults"]:
if not isinstance(item, list) or len(item) != 2 or not isinstance(item[0], str):
raise ValueError("Некорректное значение default")
expression(item[1])
names, ids, tids = set(), set(), set()
targets = []
budget = 0
def identity(obj):
ident = obj.get("id")
if not isinstance(ident, str) or not ident or len(ident) > 128 or ident in ids:
raise ValueError("Отсутствующий или повторяющийся ID")
ids.add(ident)
def name(value):
if not isinstance(value, str) or not value or value in names:
raise ValueError("Пустая или повторяющаяся метка")
names.add(value)
def body(nodes, depth=0):
nonlocal budget
if depth > 64 or not isinstance(nodes, list):
raise ValueError("Некорректная вложенность скрипта")
for n in nodes:
budget += 1
if budget > 200000 or not isinstance(n, dict) or n.get("op") not in OPS:
raise ValueError("Некорректный оператор")
identity(n)
op = n["op"]
for key in ("expr", "c", "d"):
if isinstance(n.get(key), dict):
expression(n[key])
if op in {"say", "nani_add_choice"}:
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
raise ValueError("Некорректная реплика или повторяющийся tid")
tids.add(n["tid"])
if op == "label":
name(n.get("name"))
if op in ("jump", "call", "nani_add_choice"):
if isinstance(n.get("target"), str):
targets.append(current_scope + n["target"] if n["target"].startswith(".") else n["target"])
elif not isinstance(n.get("expr"), dict):
raise ValueError("Переход должен содержать target или expr")
if op in ("if", "menu"):
children = n.get("branches" if op == "if" else "items")
if not isinstance(children, list) or not children:
raise ValueError("Пустое условие или меню")
for child in children:
if not isinstance(child, dict):
raise ValueError("Некорректная ветка")
identity(child)
cond = child.get("if" if op == "menu" else "c")
if cond is not None and not isinstance(cond, dict):
raise ValueError("Условие ветки должно быть PGVN-выражением")
if cond is not None:
expression(cond)
if op == "menu" and not isinstance(child.get("text"), str):
raise ValueError("Нужен текст варианта выбора")
body(child.get("body"), depth + 1)
if op == "menu" and n.get("say"):
body([n["say"]], depth + 1)
if op == "while":
if not isinstance(n.get("c"), dict):
raise ValueError("Нужно условие цикла")
body(n.get("body"), depth + 1)
if op == "exec":
statements(n.get("code"))
for path, module in scripts.items():
check_path(path)
if not path.startswith("script/") or not path.endswith(".json") or not isinstance(module, dict) or not isinstance(module.get("labels"), list):
raise ValueError("Некорректный модуль")
scope = ""
for label in module["labels"]:
identity(label)
label_name = label.get("name")
if not isinstance(label_name, str):
raise ValueError("Нужно имя метки")
if not label_name.startswith("."):
scope = label_name
resolved_name = scope + label_name if label_name.startswith(".") else label_name
current_scope = resolved_name.split(".")[0]
name(resolved_name)
body(label.get("body"))
if game.get("entry") not in names:
raise ValueError("Точка входа не найдена")
if any(t not in names for t in targets):
raise ValueError("Переход на отсутствующую метку")
files = manifest["files"]
for path, entry in files.items():
check_path(path)
if not BLOB_ID_RE.fullmatch(entry.get("hash", "")):
raise ValueError("Некорректный хэш")
def refs(value):
if isinstance(value, dict):
for key, v in value.items():
if key in ("file", "movie", "font", "strings", "meta", "text_font", "name_text_font", "textbox") and isinstance(v, str) and v and v not in files and v not in scripts:
raise ValueError(f"Отсутствует файл: {v}")
refs(v)
elif isinstance(value, list):
for v in value:
refs(v)
refs(game)
for module in scripts.values():
for label in module["labels"]:
for node in walk(label["body"]):
refs({k: v for k, v in node.items() if k in ("img", "file", "movie")})
def prepare(doc):
"""One-time compatibility IDs for labels/branches; existing IDs never change."""
for path, module in doc["scripts"].items():
for label in module["labels"]:
label.setdefault("id", str(uuid5(NAMESPACE_URL, path + ":" + label["name"])))
for n in walk(label["body"]):
n.setdefault("id", str(uuid4()))
for i, c in enumerate(n.get("branches", []) + n.get("items", [])):
c.setdefault("id", str(uuid5(NAMESPACE_URL, n["id"] + ":branch:" + str(i))))
if n.get("op") == "say":
n.setdefault("tid", "mod_" + n["id"])
return doc
def store(doc, manifest):
validate(doc, manifest)
files = dict(manifest["files"])
old_scripts = set(file_json(manifest, "game.json")["scripts"])
for path in old_scripts - set(doc["scripts"]):
files.pop(path, None)
for path, module in doc["scripts"].items():
module = {**module, "anchors": anchors(module["labels"])}
put_json(files, path, module)
put_json(files, "game.json", doc["game"])
from app.translation import stats
game = copy.deepcopy(doc["game"])
for code, info in game.get("locales", {}).items():
st = stats({"files": files}, code)
info.update(lines=st["translated"], progress=st["progress"], strings_count=st["stringsTranslated"])
if game != doc["game"]:
put_json(files, "game.json", game)
return {**manifest, "files": files}
def merge_order(base, left, right):
if left == base:
return right
if right == base or left == right:
return left
changes = []
for side in (left, right):
for tag, a, b, c, d in difflib.SequenceMatcher(a=base, b=side, autojunk=False).get_opcodes():
if tag != "equal":
item = (a, b, side[c:d])
if item not in changes:
changes.append(item)
changes.sort(key=lambda x: (x[0], x[1]))
for x, y in zip(changes, changes[1:]):
if y[0] < x[1] or y[0] == x[0]:
return None
out, pos = [], 0
for a, b, value in changes:
out.extend(base[pos:a])
out.extend(value)
pos = b
return out + base[pos:]
def merge_value(base, left, right, path, conflicts, choices):
if left == right or right == base:
return left
if left == base:
return right
if all(isinstance(v, dict) and "hash" in v for v in (base, left, right)):
return conflict(path, base, left, right, conflicts, choices)
if base is MISSING and isinstance(left, dict) and isinstance(right, dict):
base = {}
if all(isinstance(v, dict) for v in (base, left, right)):
out = {}
for key in sorted(set(base) | set(left) | set(right)):
part = path + "/" + key.replace("~", "~0").replace("/", "~1")
v = merge_value(base.get(key, MISSING), left.get(key, MISSING), right.get(key, MISSING), part, conflicts, choices)
if v is not MISSING:
out[key] = v
return out
if all(isinstance(v, list) for v in (base, left, right)):
all_items = base + left + right
if all_items and all(isinstance(v, dict) and isinstance(v.get("id"), str) for v in all_items):
maps = [{v["id"]: v for v in seq} for seq in (base, left, right)]
items = {}
for key in sorted(set(maps[0]) | set(maps[1]) | set(maps[2])):
item = merge_value(*(m.get(key, MISSING) for m in maps), path + "/@" + key, conflicts, choices)
if item is not MISSING:
items[key] = item
seqs = [[v["id"] for v in seq if v["id"] in items] for seq in (base, left, right)]
order = merge_order(*seqs)
if order is None:
order = conflict(path + "/$order", *seqs, conflicts, choices)
order = list(dict.fromkeys(order + [k for k in items if k not in order]))
return [items[k] for k in order if k in items]
return conflict(path, base, left, right, conflicts, choices)
def conflict(path, base, left, right, conflicts, choices):
if choices.get(path) in ("left", "right"):
return left if choices[path] == "left" else right
conflicts.append({"path": path, "base": None if base is MISSING else base,
"left": None if left is MISSING else left, "right": None if right is MISSING else right,
"leftDeleted": left is MISSING, "rightDeleted": right is MISSING})
return left
def comparable(doc):
doc = copy.deepcopy(doc)
for module in doc["scripts"].values():
module.pop("anchors", None)
return doc
def differences(base, current, path=""):
"""Bounded structural diff, using IDs rather than array positions for commands."""
if base == current:
return []
if isinstance(base, dict) and isinstance(current, dict):
out = []
for key in sorted(set(base) | set(current)):
out.extend(differences(base.get(key), current.get(key), path + "/" + key))
return out
if isinstance(base, list) and isinstance(current, list) and base + current and all(isinstance(v, dict) and "id" in v for v in base + current):
a, b = ({v["id"]: v for v in seq} for seq in (base, current))
out = differences(a, b, path)
if [v["id"] for v in base] != [v["id"] for v in current]:
out.append({"path": path + "/$order", "before": list(a), "after": list(b)})
return out
return [{"path": path, "before": base, "after": current}]
def fingerprint(node):
return json.dumps({k: v for k, v in node.items() if k not in ("id", "tid", "body", "items", "branches", "say")}, sort_keys=True, ensure_ascii=False)
def align(base, target, manual=None):
"""Only unique semantic matches within a named label. Ordinal IDs are NEVER evidence."""
target = copy.deepcopy(target)
manual = manual or {}
pairs, unresolved, used = {}, [], set()
old_nodes, new_nodes = {}, {}
for path, module in base["scripts"].items():
other = target["scripts"].get(path, {})
by_name = {l["name"]: l for l in other.get("labels", [])}
for label in module["labels"]:
new_label = by_name.get(label["name"])
old = list(walk(label["body"]))
old_nodes.update((n["id"], n) for n in old)
new = list(walk(new_label["body"])) if new_label else []
new_nodes.update((n["id"], n) for n in new)
if new_label:
new_label["id"] = label["id"]
for n in old:
same_old = [o for o in old if fingerprint(o) == fingerprint(n)]
candidates = [o for o in new if fingerprint(o) == fingerprint(n)]
chosen = manual.get(n["id"])
if chosen is not None:
candidates = [o for o in new if o["id"] == chosen]
if len(candidates) != 1 or candidates[0]["op"] != n["op"]:
raise ValueError("Некорректное ручное сопоставление")
if len(candidates) == 1 and (chosen is not None or len(same_old) == 1):
nid = candidates[0]["id"]
if nid in used:
raise ValueError("Узел сопоставлен дважды")
used.add(nid)
pairs[nid] = n
else:
unresolved.append({"id": n["id"], "module": path, "label": label["name"],
"op": n["op"], "text": n.get("what", n.get("op")),
"candidates": [{"id": o["id"], "text": o.get("what", o["op"])} for o in new if o["op"] == n["op"]]})
unknown = set(manual) - set(old_nodes)
if unknown:
raise ValueError("Неизвестный ID базы")
for module in target["scripts"].values():
for label in module["labels"]:
for node in walk(label["body"]):
original = pairs.get(node["id"])
node["id"] = original["id"] if original else str(uuid5(NAMESPACE_URL, "rebase:" + node["id"] + ":" + fingerprint(node)))
if original and "tid" in original:
node["tid"] = original["tid"]
for key in ("branches", "items"):
previous = original.get(key, []) if original else []
for i, child in enumerate(node.get(key, [])):
matches = [p for p in previous if {k: v for k, v in p.items() if k not in ("id", "body")} == {k: v for k, v in child.items() if k not in ("id", "body")}]
child["id"] = matches[0]["id"] if len(matches) == 1 else str(uuid5(NAMESPACE_URL, node["id"] + ":" + key + ":" + str(i)))
return target, unresolved
+135
View File
@@ -0,0 +1,135 @@
"""Validation of the expression/statement AST accepted by the web interpreter."""
def expression(value, depth=0):
if depth > 64 or not isinstance(value, dict):
raise ValueError("Некорректное PGVN-выражение")
kind = value.get("k")
def ex(key, optional=False):
v = value.get(key)
if not optional or v is not None:
expression(v, depth + 1)
def seq(key):
v = value.get(key)
if not isinstance(v, list):
raise ValueError(f"Выражение {kind}: нужен список {key}")
return v
if kind == "nani":
nani_expression(value.get("ast"), depth + 1)
elif kind == "const":
if "v" not in value:
raise ValueError("const без значения")
elif kind == "name":
if not isinstance(value.get("id"), str):
raise ValueError("Имя переменной должно быть строкой")
elif kind == "attr":
ex("o")
if not isinstance(value.get("a"), str):
raise ValueError("Атрибут должен быть строкой")
elif kind in ("sub", "slice"):
ex("o")
if kind == "sub":
ex("i")
else:
for key in ("lo", "hi", "st"):
ex(key, True)
elif kind in ("bin", "unary", "bool"):
from app.converter.pyexpr import BIN_OPS, UNARY_OPS
allowed = set(BIN_OPS.values()) if kind == "bin" else set(UNARY_OPS.values()) if kind == "unary" else {"and", "or"}
if value.get("op") not in allowed:
raise ValueError("Неизвестная операция выражения")
if kind == "bin":
ex("l")
ex("r")
elif kind == "unary":
ex("v")
else:
for item in seq("v"):
expression(item, depth + 1)
elif kind == "cmp":
from app.converter.pyexpr import CMP_OPS
ex("l")
operators, operands = seq("ops"), seq("r")
if len(operators) != len(operands) or any(op not in CMP_OPS.values() for op in operators):
raise ValueError("Некорректное сравнение")
for item in operands:
expression(item, depth + 1)
elif kind == "if":
for key in ("c", "t", "e"):
ex(key)
elif kind in ("list", "tuple", "fmt", "dict"):
for item in seq("v"):
if kind == "dict":
if not isinstance(item, list) or len(item) != 2:
raise ValueError("Некорректная запись словаря")
for part in item:
expression(part, depth + 1)
elif kind != "fmt" or not isinstance(item, str):
expression(item, depth + 1)
elif kind == "call":
ex("f")
for item in seq("a"):
expression(item, depth + 1)
if not isinstance(value.get("kw"), dict):
raise ValueError("Некорректные аргументы вызова")
for item in value["kw"].values():
expression(item, depth + 1)
elif kind == "comp":
for key in ("e", "t", "it"):
ex(key)
for item in seq("if"):
expression(item, depth + 1)
else:
raise ValueError(f"Неизвестное выражение: {kind}")
def statements(code, depth=0):
if depth > 64 or not isinstance(code, list):
raise ValueError("Некорректный PGVN AST")
for s in code:
if not isinstance(s, dict):
raise ValueError("Некорректный оператор AST")
op = s.get("op")
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": ()}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
for key in keys[op]:
expression(s.get(key), depth + 1)
if op == "if":
statements(s.get("then"), depth + 1)
statements(s.get("else"), depth + 1)
if op in ("while", "for"):
statements(s.get("body"), depth + 1)
def nani_expression(node, depth=0):
"""Validate the optional Naninovel AST already supported by the shared player."""
if depth > 64 or not isinstance(node, list) or not node:
raise ValueError("Некорректный AST Naninovel")
kind = node[0]
if kind == "const" and len(node) == 2 and (node[1] is None or isinstance(node[1], (str, int, float, bool))):
return
if kind == "name" and len(node) == 2 and isinstance(node[1], str):
return
children = None
if kind == "unary" and len(node) == 3 and node[1] in ("!", "not", "+", "-", "~"):
children = node[2:]
elif kind == "bin" and len(node) == 4:
from app.converter.nani_expr import PRECEDENCE
if node[1] in PRECEDENCE:
children = node[2:]
elif kind == "if" and len(node) == 4:
children = node[1:]
elif kind == "call" and len(node) == 3:
from app.converter.nani_expr import FUNCTIONS
if node[1] in FUNCTIONS and isinstance(node[2], list):
children = node[2]
if children is None:
raise ValueError("Неизвестная операция AST Naninovel")
for child in children:
nani_expression(child, depth + 1)
+24
View File
@@ -0,0 +1,24 @@
"""Capture every manifest change, including translation/import writers."""
import copy
from uuid import uuid4
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from app.models import GameVersion, Revision
@event.listens_for(Session, "before_flush")
def capture_revisions(session, flush_context, instances):
for obj in list(session.new) + list(session.dirty):
if not isinstance(obj, GameVersion) or not obj.manifest or obj in session.deleted:
continue
if obj not in session.new and not inspect(obj).attrs.manifest.history.has_changes():
continue
parents = list(getattr(obj, "_revision_parents", None) or
([obj.revision_id] if obj.revision_id else []))
rid = str(uuid4())
session.add(Revision(id=rid, game_id=obj.game_id, user_id=session.info.get("user_id", obj.owner_id),
parents=parents, manifest=copy.deepcopy(obj.manifest)))
obj.revision_id = rid
obj._revision_parents = None
+1
View File
@@ -126,6 +126,7 @@ async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str =
items.append({ items.append({
"key": info.tid, "nodeId": info.node_id, "module": info.module, "key": info.tid, "nodeId": info.node_id, "module": info.module,
"label": info.label, "who": info.who, "source": info.source, "label": info.label, "who": info.who, "source": info.source,
"resource": info.resource,
"prev": info.prev, "next": info.next, "prev": info.prev, "next": info.next,
"scene": f"/blobs/{scene['hash']}" if scene else None, "scene": f"/blobs/{scene['hash']}" if scene else None,
"translation": (e or {}).get("t", ""), "state": st, "translation": (e or {}).get("t", ""), "state": st,
+54
View File
@@ -0,0 +1,54 @@
"""Authenticated media delivery; nginx only receives validated internal paths."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy import exists, or_, select, text
from app.blobs import BLOB_ID_RE, blob_path
from app.deps import DB, require
from app.models import Game, GameVersion, User
from app.routers.games import visible_versions_filter
from app.routers.versions import get_version
router = APIRouter(tags=["media"])
Player = Annotated[User, Depends(require("games.play"))]
def deliver(blob_id: str, request: Request):
if not BLOB_ID_RE.fullmatch(blob_id) or not blob_path(blob_id).is_file():
raise HTTPException(404)
headers = {"Cache-Control": "private, no-cache", "ETag": f'"{blob_id}"',
"X-Content-Type-Options": "nosniff", "Vary": "Cookie"}
if request.headers.get("if-none-match") == headers["ETag"]:
return Response(status_code=304, headers=headers)
if request.headers.get("x-pgames-accel") == "1":
headers["X-Accel-Redirect"] = f"/_protected_blobs/{blob_id[:2]}/{blob_id[2:4]}/{blob_id}"
return Response(headers=headers)
return FileResponse(blob_path(blob_id), headers=headers)
@router.api_route("/api/versions/{version_id}/files/{blob_id}", methods=["GET", "HEAD"])
async def version_file(version_id: int, blob_id: str, request: Request, user: Player, db: DB):
v = await get_version(db, user, version_id)
if not v.manifest or not any(f["hash"] == blob_id for f in v.manifest["files"].values()):
raise HTTPException(404)
return deliver(blob_id, request)
@router.api_route("/blobs/{blob_id}", methods=["GET", "HEAD"])
async def legacy_file(blob_id: str, request: Request, user: Player, db: DB):
"""Compatibility for cached cover URLs; hashes never grant access themselves."""
if not BLOB_ID_RE.fullmatch(blob_id):
raise HTTPException(404)
visible = visible_versions_filter(user)
cover = await db.scalar(select(Game.id).where(Game.cover_hash == blob_id,
or_(Game.created_by_id == user.id, exists().where(GameVersion.game_id == Game.id, visible))).limit(1))
if cover is None:
# Keep old clients functional, with exactly the same version visibility rules.
found = await db.scalar(select(GameVersion.id).where(visible,
text("EXISTS (SELECT 1 FROM jsonb_each(game_versions.manifest->'files') f WHERE f.value->>'hash' = :blob)")
).params(blob=blob_id).limit(1))
if found is None:
raise HTTPException(404)
return deliver(blob_id, request)
+305
View File
@@ -0,0 +1,305 @@
"""Mod editor, revision history, three-way merge and rebase."""
import copy
import tempfile
from pathlib import Path
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from app import mods
from app.blobs import put_file
from app.config import get_settings
from app.converter import assets
from app.deps import DB, has_perm, require
from app.models import GameVersion, Revision, User
from app.routers.versions import can_manage, get_version, lock_version
from app.translation import file_json, put_json
router = APIRouter(prefix="/api/versions", tags=["mods"])
Player = Annotated[User, Depends(require("games.play"))]
Editor = Annotated[User, Depends(require("games.play", "versions.fork"))]
def editable(v, user):
if v.status != "ready" or not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
if v.kind != "mod" or v.visibility != "draft" or not can_manage(user, v):
raise HTTPException(403, "Редактировать можно свой черновик мода")
def expected(v, revision):
if v.revision_id != revision:
raise HTTPException(409, "Версия изменилась. Загрузите её заново; локальные правки сохранены в редакторе")
def update_manifest(v, manifest):
v.manifest = manifest
v.size_bytes = sum(f["size"] for f in manifest["files"].values())
@router.get("/{version_id}/editor")
async def read_editor(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
if not v.manifest:
raise HTTPException(409, "Версия ещё не готова")
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
old = base.manifest["files"] if base else {}
changes = [p for p in sorted(set(old) | set(v.manifest["files"]))
if old.get(p) != v.manifest["files"].get(p)] if base else []
diff = await run_in_threadpool(lambda: mods.differences(
mods.comparable(mods.prepare(mods.document(base.manifest))),
mods.comparable(mods.prepare(mods.document(v.manifest))))) if base else []
return {"revision": v.revision_id, "baseRevision": v.base_revision_id, "changes": changes,
"diff": diff,
"document": await run_in_threadpool(lambda: mods.prepare(mods.document(v.manifest))),
"files": v.manifest["files"], "editable": has_perm(user, "versions.fork") and
v.kind == "mod" and v.visibility == "draft" and can_manage(user, v)}
class EditIn(BaseModel):
revision: str
document: dict[str, Any]
@router.put("/{version_id}/editor")
async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
try:
manifest = await run_in_threadpool(mods.store, body.document, v.manifest)
except (ValueError, KeyError, TypeError, RecursionError) as e:
raise HTTPException(422, f"Некорректный PGVN: {e}") from None
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
@router.put("/{version_id}/asset")
async def replace_asset(version_id: int, path: str, revision: str, filename: str,
request: Request, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, revision)
if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")):
raise HTTPException(422, "Выберите существующий медиафайл")
suffix = Path(filename).suffix.lower()
if assets.kind_of(Path(path)) not in ("image", "audio", "video", "font") or assets.kind_of(Path(filename)) != assets.kind_of(Path(path)):
raise HTTPException(422, "Тип загруженного файла не совпадает с заменяемым")
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
root = Path(folder)
source = root / ("upload" + suffix)
count = 0
with source.open("wb") as out:
async for chunk in request.stream():
count += len(chunk)
if count > 512 * 1024 * 1024:
raise HTTPException(413, "Максимум 512 МБ")
out.write(chunk)
if not count:
raise HTTPException(422, "Пустой файл")
def convert():
# Do not accept unparsed webm passthrough for editor uploads.
if suffix == ".webm":
output = assets._ffmpeg(source, root / "verified.webm",
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", "-cpu-used", "8", "-c:a", "libopus"])
return put_file(output, ".webm", move=True)
if assets.kind_of(source) == "font":
signatures = {".ttf": b"\x00\x01\x00\x00", ".otf": b"OTTO", ".woff": b"wOFF", ".woff2": b"wOF2", ".ttc": b"ttcf"}
with source.open("rb") as stream:
if stream.read(4) != signatures.get(suffix):
raise ValueError("Некорректный шрифт")
output, ext = assets.convert(source, root)
return put_file(output, ext, move=True)
try:
blob_id, size = await run_in_threadpool(convert)
except Exception as e:
raise HTTPException(422, f"Не удалось обработать файл: {e}") from None
manifest = copy.deepcopy(v.manifest)
manifest["files"][path] = {"hash": blob_id, "size": size}
update_manifest(v, manifest)
await db.commit()
return {"revision": v.revision_id, "files": v.manifest["files"]}
async def revision_graph(db, game_id):
return {r.id: r for r in (await db.scalars(select(Revision).where(Revision.game_id == game_id))).all()}
def ancestors(graph, rid):
found, queue = {}, [(rid, 0)]
while queue:
key, depth = queue.pop(0)
if key in found or key not in graph:
continue
found[key] = depth
queue.extend((p, depth + 1) for p in graph[key].parents)
return found
@router.get("/{version_id}/revisions")
async def history(version_id: int, user: Player, db: DB):
v = await get_version(db, user, version_id)
graph = await revision_graph(db, v.game_id)
reachable = ancestors(graph, v.revision_id)
return [{"id": key, "parents": graph[key].parents, "created_at": graph[key].created_at,
"user_id": graph[key].user_id} for key in sorted(reachable, key=lambda k: graph[k].created_at, reverse=True)]
class RestoreIn(BaseModel):
revision: str
restore: str
@router.post("/{version_id}/revisions/restore")
async def restore(version_id: int, body: RestoreIn, user: Editor, db: DB):
await get_version(db, user, version_id)
v = await lock_version(db, version_id)
editable(v, user)
expected(v, body.revision)
graph = await revision_graph(db, v.game_id)
if body.restore not in ancestors(graph, v.revision_id):
raise HTTPException(404, "Ревизия не найдена")
update_manifest(v, copy.deepcopy(graph[body.restore].manifest))
await db.commit()
return {"revision": v.revision_id}
class MergeIn(BaseModel):
target_id: int
source_revision: str
target_revision: str
mode: Literal["merge", "rebase"] = "merge"
choices: dict[str, Literal["left", "right"]] = Field(default_factory=dict)
mapping: dict[str, str] = Field(default_factory=dict)
name: str = Field(default="Объединённый мод", min_length=1, max_length=255)
apply: bool = False
def merge_tree(manifest):
doc = mods.comparable(mods.prepare(mods.document(manifest)))
for info in doc["game"].get("locales", {}).values():
for key in ("progress", "lines", "strings_count"):
info.pop(key, None)
extras = {}
for p, entry in manifest["files"].items():
if p == "game.json" or p in doc["scripts"]:
continue
extras[p] = {"json": copy.deepcopy(file_json(manifest, p))} if p.startswith("locales/") and p.endswith(".json") else entry
return {"document": doc, "files": extras}
@router.post("/{version_id}/merge")
async def merge(version_id: int, body: MergeIn, user: Editor, db: DB):
source = await get_version(db, user, version_id)
target = await get_version(db, user, body.target_id)
if source.game_id != target.game_id or source.id == target.id or source.kind != "mod":
raise HTTPException(422, "Нужен мод и другая версия той же игры")
if source.status != "ready" or target.status != "ready":
raise HTTPException(409, "Версии ещё не готовы")
if body.apply:
# Stable lock ordering avoids deadlocks in simultaneous opposite merges.
locked = {key: await lock_version(db, key) for key in sorted((source.id, target.id))}
source, target = locked[source.id], locked[target.id]
expected(source, body.source_revision)
expected(target, body.target_revision)
graph = await revision_graph(db, source.game_id)
if body.mode == "rebase":
base_id = source.base_revision_id
else:
a, b = ancestors(graph, source.revision_id), ancestors(graph, target.revision_id)
common = set(a) & set(b)
base_id = min(common, key=lambda k: (max(a[k], b[k]), a[k] + b[k], k)) if common else None
if not base_id or base_id not in graph:
raise HTTPException(409, "Нет достоверной базовой ревизии. Для старого мода создайте новый форк и перенесите правки вручную")
def build():
base = merge_tree(graph[base_id].manifest)
left, right = merge_tree(source.manifest), merge_tree(target.manifest)
unmatched = []
aliases, target_tids = {}, {}
if body.mode == "rebase":
before = right["document"]
right["document"], unmatched = mods.align(base["document"], before, body.mapping)
def identities(raw, aligned):
if isinstance(raw, dict) and isinstance(aligned, dict):
if raw.get("id") and aligned.get("id"):
aliases[aligned["id"]] = raw["id"]
if raw.get("tid") and aligned.get("tid"):
target_tids[aligned["tid"]] = raw["tid"]
for key in raw.keys() & aligned.keys():
identities(raw[key], aligned[key])
elif isinstance(raw, list) and isinstance(aligned, list):
for a, b in zip(raw, aligned):
identities(a, b)
identities(before, right["document"])
from app.converter.locales import walk
tids = {}
for path, module in before["scripts"].items():
for old_label, new_label in zip(module["labels"], right["document"]["scripts"][path]["labels"]):
for old_node, new_node in zip(walk(old_label["body"]), walk(new_label["body"])):
if old_node.get("tid") and new_node.get("tid"):
tids[old_node["tid"]] = new_node["tid"]
for entry in right["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {tids.get(k, k): v for k, v in entry["json"].items()}
conflicts = []
result = mods.merge_value(base, left, right, "", conflicts, body.choices)
if body.mode == "rebase" and body.apply and not conflicts:
# Compare in old-base identity, then adopt the new original's canonical
# IDs. This makes the frozen target a valid base for the NEXT rebase.
def canonical(value):
if isinstance(value, dict):
for key, v in value.items():
if key == "id" and isinstance(v, str):
value[key] = aliases.get(v, v)
elif key == "tid" and isinstance(v, str):
value[key] = target_tids.get(v, v)
else:
canonical(v)
elif isinstance(value, list):
for v in value:
canonical(v)
canonical(result["document"])
for entry in result["files"].values():
if set(entry) == {"json"} and isinstance(entry["json"], dict):
entry["json"] = {target_tids.get(k, k): v for k, v in entry["json"].items()}
return result, conflicts, unmatched
try:
result, conflicts, unmatched = await run_in_threadpool(build)
except (ValueError, TypeError, KeyError) as e:
raise HTTPException(422, str(e)) from None
if not body.apply:
return {"baseRevision": base_id, "conflicts": conflicts, "unmatched": unmatched,
"document": result["document"]}
if conflicts:
raise HTTPException(409, "Остались нерешённые конфликты")
def write():
manifest = {"format": "pgvn/1", "files": {}}
# Keep script entries until store() replaces them and validates references.
for p, f in target.manifest["files"].items():
if p == "game.json" or p in result["document"]["scripts"]:
manifest["files"][p] = f
for p, entry in result["files"].items():
if set(entry) == {"json"}:
put_json(manifest["files"], p, entry["json"])
else:
manifest["files"][p] = entry
return mods.store(result["document"], manifest)
try:
manifest = await run_in_threadpool(write)
except (ValueError, KeyError, TypeError) as e:
raise HTTPException(422, f"Результат не прошёл проверку: {e}") from None
fork = GameVersion(game_id=source.game_id, parent_id=target.id, owner_id=user.id,
name=body.name, kind="mod", visibility="draft", status="ready", manifest=manifest,
base_revision_id=target.revision_id if body.mode == "rebase" else base_id,
size_bytes=sum(f["size"] for f in manifest["files"].values()))
fork._revision_parents = [source.revision_id, target.revision_id]
db.add(fork)
await db.commit()
return {"id": fork.id, "revision": fork.revision_id}
+34 -8
View File
@@ -1,11 +1,13 @@
from typing import Annotated, Any from typing import Annotated, Any
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, Save, User, VersionHistory from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game from app.routers.games import get_game
from app.schemas import ( from app.schemas import (
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
@@ -77,8 +79,20 @@ async def upload_version(
await db.commit() await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
from app.converter.preflight import probe_archive
from app.converter.archive import ArchiveError
from app.converter.compatibility import CompatibilityError
try:
probe = await asyncio.to_thread(probe_archive, path, filename)
except (CompatibilityError, ArchiveError, OSError, ValueError) as exc:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(422, str(exc)) from None
version.status = "queued" version.status = "queued"
version.progress = "В очереди на конвертацию" version.progress = "В очереди на проверку совместимости"
version.report = {"engine": probe["engine"], "compatible": None}
version.size_bytes = size version.size_bytes = size
await db.commit() await db.commit()
return version return version
@@ -94,7 +108,7 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest: if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest return {**v.manifest, "revision": v.revision_id, "versionId": v.id}
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
@@ -117,9 +131,11 @@ async def fork_version(version_id: int, body: ForkIn,
visibility="draft", visibility="draft",
kind=body.kind, kind=body.kind,
manifest=parent.manifest, manifest=parent.manifest,
base_revision_id=parent.revision_id,
report=parent.report, report=parent.report,
size_bytes=parent.size_bytes, size_bytes=parent.size_bytes,
) )
fork._revision_parents = [parent.revision_id] if parent.revision_id else []
db.add(fork) db.add(fork)
await db.commit() await db.commit()
return fork return fork
@@ -147,15 +163,25 @@ async def publish_version(version_id: int, body: PublishIn, user: Publisher, db:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
game = await db.get(Game, v.game_id) game = await db.get(Game, v.game_id)
if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id: if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id:
main = await lock_version(db, game.main_version_id) locked = {key: await lock_version(db, key) for key in sorted((v.id, game.main_version_id))}
parent = await db.get(GameVersion, v.parent_id) if v.parent_id else None v, main = locked[v.id], locked[game.main_version_id]
langs = translation.changed_languages(v.manifest, parent.manifest if parent else None) base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
if base is None:
raise HTTPException(409, "Историческая база перевода неизвестна. Создайте новый форк перевода")
langs = translation.changed_languages(v.manifest, base.manifest)
if not langs: if not langs:
raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов") raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов")
merged, conflicts = await run_in_threadpool(translation.merge_translation, base.manifest, v.manifest, main.manifest)
if conflicts:
raise HTTPException(409, "Конфликт перевода; согласуйте изменения строк: " + ", ".join(c["path"] for c in conflicts[:10]))
db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest, db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest,
note=f"Перевод из «{v.name}»: {', '.join(langs)}")) note=f"Перевод из «{v.name}»: {', '.join(langs)}"))
main.manifest = translation.merge_languages(main.manifest, v.manifest, langs) main._revision_parents = [main.revision_id, v.revision_id]
elif body.make_main: main.manifest = merged
main.size_bytes = sum(f["size"] for f in merged["files"].values())
else:
v = await lock_version(db, v.id)
if body.make_main:
game.main_version_id = v.id game.main_version_id = v.id
v.visibility = "published" v.visibility = "published"
await db.commit() await db.commit()
+2
View File
@@ -66,6 +66,8 @@ class OwnerOut(ORM):
class VersionOut(ORM): class VersionOut(ORM):
revision_id: str | None = None
base_revision_id: str | None = None
id: int id: int
game_id: int game_id: int
parent_id: int | None parent_id: int | None
+51 -1
View File
@@ -51,6 +51,7 @@ class LineInfo:
scene: str | None # logical path of the background shown at this line scene: str | None # logical path of the background shown at this line
prev: str | None = None prev: str | None = None
next: str | None = None next: str | None = None
resource: dict[str, Any] | None = None
@dataclass @dataclass
@@ -94,13 +95,14 @@ def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIn
idx.strings.setdefault(item["text"], "menu") idx.strings.setdefault(item["text"], "menu")
elif op == "show" and isinstance((node.get("img") or {}).get("text"), str): elif op == "show" and isinstance((node.get("img") or {}).get("text"), str):
idx.strings.setdefault(node["img"]["text"], "text") 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 "" who = node.get("whoStr") or ""
if not who and node.get("who"): if not who and node.get("who"):
c = chars.get(node["who"], {}) c = chars.get(node["who"], {})
who = c.get("name") if isinstance(c.get("name"), str) else 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 "", info = LineInfo(node["tid"], node.get("id", ""), path, group, who or "",
node["what"], scene) node["what"], scene)
info.resource = node.get("resource")
if idx.lines and idx.lines[-1].module == path: if idx.lines and idx.lines[-1].module == path:
idx.lines[-1].next = info.source idx.lines[-1].next = info.source
info.prev = idx.lines[-1].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 {} table = dict(file_json(manifest, path) or {}) if path in files else {}
for tid, value in changes.items(): for tid, value in changes.items():
e = entry(value, idx.by_tid[tid].source) if value else None 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: if e:
table[tid] = e table[tid] = e
else: else:
@@ -265,6 +269,19 @@ def apply_changes(manifest: dict[str, Any], lang: str, *, lines: dict[str, Any]
return manifest 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] def merge_languages(target: dict[str, Any], source: dict[str, Any], langs: list[str]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Copy whole languages from one manifest into another (publishing a translation).""" """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 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 -------------------------------------------------------------------------- # --- PO / CSV --------------------------------------------------------------------------
def _po_quote(s: str) -> str: def _po_quote(s: str) -> str:
+17 -2
View File
@@ -10,6 +10,7 @@ from sqlalchemy import select, update
from app.blobs import read_bytes from app.blobs import read_bytes
from app.config import get_settings from app.config import get_settings
from app.converter.pipeline import convert_archive from app.converter.pipeline import convert_archive
from app.converter.compatibility import CompatibilityError
from app.db import SessionLocal from app.db import SessionLocal
from app.models import Game, GameVersion from app.models import Game, GameVersion
from app.routers.games import make_cover from app.routers.games import make_cover
@@ -36,7 +37,7 @@ async def claim() -> GameVersion | None:
return None return None
v = await db.get(GameVersion, version_id) v = await db.get(GameVersion, version_id)
v.status = "converting" v.status = "converting"
v.progress = "Начало конвертации" v.progress = "Проверка совместимости игры"
await db.commit() await db.commit()
return v return v
@@ -48,6 +49,18 @@ async def process(v: GameVersion) -> None:
async def convert(version_id: int, source: Path, filename: str) -> bool: async def convert(version_id: int, source: Path, filename: str) -> bool:
from app.maintenance import storage_access
async with storage_access():
task = asyncio.create_task(_convert(version_id, source, filename))
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
# Conversion threads cannot be cancelled. Keep the lease until commit.
await task
raise
async def _convert(version_id: int, source: Path, filename: str) -> bool:
"""Run the converter for a version and store the outcome. Returns success.""" """Run the converter for a version and store the outcome. Returns success."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
work = get_settings().work_dir / str(version_id) work = get_settings().work_dir / str(version_id)
@@ -65,7 +78,7 @@ async def convert(version_id: int, source: Path, filename: str) -> bool:
if version: if version:
version.status = "failed" version.status = "failed"
version.progress = f"Ошибка: {e}"[:255] version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)} version.report = e.report() if isinstance(e, CompatibilityError) else {"error": str(e)}
await db.commit() await db.commit()
return False return False
finally: finally:
@@ -81,6 +94,8 @@ async def convert(version_id: int, source: Path, filename: str) -> bool:
version.report = result.report version.report = result.report
version.size_bytes = result.size version.size_bytes = result.size
game = await db.get(Game, version.game_id) game = await db.get(Game, version.game_id)
if game:
game.engine = result.report.get("engine", "renpy")
if game and not game.cover_hash and result.cover_source: if game and not game.cover_hash and result.cover_source:
try: try:
game.cover_hash = await asyncio.to_thread(make_cover, game.cover_hash = await asyncio.to_thread(make_cover,
@@ -0,0 +1,36 @@
"""Immutable revisions. Old fork bases cannot be reconstructed reliably."""
import json
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0003"
down_revision = "0002"
branch_labels = depends_on = None
def upgrade():
op.create_table("revisions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("game_id", sa.Integer, sa.ForeignKey("games.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("parents", postgresql.ARRAY(sa.String), nullable=False),
sa.Column("manifest", postgresql.JSONB, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False))
op.create_index("ix_revisions_game_id", "revisions", ["game_id"])
op.add_column("game_versions", sa.Column("revision_id", sa.String(36)))
op.add_column("game_versions", sa.Column("base_revision_id", sa.String(36)))
bind = op.get_bind()
for row in bind.execute(sa.text("SELECT id, game_id, owner_id, manifest FROM game_versions WHERE manifest IS NOT NULL")).mappings():
rid = str(uuid.uuid4())
bind.execute(sa.text("INSERT INTO revisions (id, game_id, user_id, parents, manifest) VALUES (:id, :game, :user, '{}', CAST(:manifest AS jsonb))"),
dict(id=rid, game=row["game_id"], user=row["owner_id"], manifest=json.dumps(row["manifest"])))
bind.execute(sa.text("UPDATE game_versions SET revision_id=:rid WHERE id=:id"), dict(rid=rid, id=row["id"]))
def downgrade():
op.drop_column("game_versions", "base_revision_id")
op.drop_column("game_versions", "revision_id")
op.drop_table("revisions")
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.1.1
httpx==0.28.1
+2
View File
@@ -7,3 +7,5 @@ pydantic-settings==2.9.1
argon2-cffi==23.1.0 argon2-cffi==23.1.0
pyjwt==2.10.1 pyjwt==2.10.1
pillow==11.2.1 pillow==11.2.1
UnityPy==1.25.4
TypeTreeGeneratorAPI==0.0.10
+2 -2
View File
@@ -18,8 +18,8 @@ transform slightleft:
xalign 0.3 yalign 1.0 xalign 0.3 yalign 1.0
init python: init python:
def helper():
return 1
bonus = 5 bonus = 5
persistent.seen_intro = False persistent.seen_intro = False
+100
View File
@@ -0,0 +1,100 @@
"""Disposable local UI fixture. Run seed/cleanup only on the pgames-test stand."""
import argparse
import asyncio
import json
import tempfile
from pathlib import Path
from uuid import uuid4
from sqlalchemy import delete
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.maintenance import storage_access
from app.models import Game, GameVersion, Role, User
from app.security import hash_password
from tests.make_sample import build
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("seed", "check", "cleanup"))
args = parser.parse_args()
record = get_settings().data_dir / "mod-ui-fixture.json"
if args.action == "check":
import http.cookiejar
import urllib.error
import urllib.request
data = json.loads(record.read_text())
host = "http://web"
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
payload = json.dumps({"username": data["username"], "password": data["password"]}).encode()
opener.open(urllib.request.Request(host + "/api/auth/login", data=payload, headers={"Content-Type": "application/json"}))
manifest = json.load(opener.open(host + f'/api/versions/{data["version_id"]}/manifest'))
key = manifest["files"]["game.json"]["hash"]
file_url = host + f'/api/versions/{data["version_id"]}/files/{key}'
response = opener.open(file_url)
assert response.status == 200 and "private" in response.headers["Cache-Control"]
assert json.load(response)["format"] == "pgvn/1"
assert opener.open(urllib.request.Request(file_url, method="HEAD")).status == 200
audio = next(f["hash"] for p, f in manifest["files"].items() if p.endswith(".ogg"))
response = opener.open(urllib.request.Request(host + f'/api/versions/{data["version_id"]}/files/{audio}', headers={"Range": "bytes=0-9"}))
assert response.status == 206 and len(response.read()) == 10
for url, status in [(file_url, 401), (host + f"/_protected_blobs/{key[:2]}/{key[2:4]}/{key}", 404)]:
try:
urllib.request.urlopen(url)
except urllib.error.HTTPError as error:
assert error.code == status
else:
raise AssertionError("Unprotected media route")
print("nginx: authenticated JSON/HEAD/Range OK; anonymous and direct internal access denied")
return
async with storage_access():
async with SessionLocal() as db:
if args.action == "cleanup":
if not record.exists():
return
data = json.loads(record.read_text())
user = await db.get(User, data["user_id"])
if not user or user.username != data["username"] or not user.username.startswith("mod-ui-"):
raise RuntimeError("Fixture identity mismatch; cleanup aborted")
await db.execute(delete(Game).where(Game.created_by_id == user.id))
await db.delete(user)
await db.commit()
await db.execute(delete(Role).where(Role.id == data["role_id"]))
await db.commit()
record.unlink()
print("Disposable UI user and games removed")
return
if record.exists():
print(record.read_text())
return
suffix = uuid4().hex[:10]
password = uuid4().hex
role = Role(name="mod-ui-" + suffix, permissions=["games.play", "versions.fork", "games.upload", "games.edit", "versions.publish"])
db.add(role)
await db.flush()
user = User(username="mod-ui-" + suffix, password_hash=hash_password(password), role_id=role.id, must_change_password=False)
db.add(user)
await db.flush()
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
result = await asyncio.to_thread(convert_archive, build(root / "sample.zip"), "sample.zip", root / "work", lambda _: None)
game = Game(title="Проверка редактора модов", created_by_id=user.id)
db.add(game)
await db.flush()
original = GameVersion(game_id=game.id, owner_id=user.id, name="Оригинал для проверки", status="ready", manifest=result.manifest)
db.add(original)
await db.flush()
fork = GameVersion(game_id=game.id, parent_id=original.id, owner_id=user.id, name="Мой тестовый мод", kind="mod", visibility="draft", status="ready", manifest=result.manifest, base_revision_id=original.revision_id)
fork._revision_parents = [original.revision_id]
db.add(fork)
await db.commit()
data = dict(username=user.username, password=password, user_id=user.id, role_id=role.id, version_id=fork.id, game_id=game.id)
record.write_text(json.dumps(data))
print(json.dumps(data))
if __name__ == "__main__":
asyncio.run(main())
+279
View File
@@ -0,0 +1,279 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import zipfile
import pytest
from app.config import get_settings
from app.converter.compatibility import CompatibilityError, audit_ir, detect, require_supported_scripts
from app.converter.nani_compiler import compile_package
from app.converter.nani_expr import assignment, parse
from app.converter.nani_package import Package, decode_script, text_records
from app.converter.pipeline import convert_archive
from app.converter.preflight import inspect_directory, probe_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from app.import_library import check, entries
from app.translation import LineInfo, validate_nani_translation
def write(path, text=""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def script(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def package(*commands):
return Package(scripts={"Opening": {"name": "Opening", "lines": [
{"line": i, "commands": [command]} for i, command in enumerate(commands)]}})
def command(op, **args):
return {"op": op, "args": args}
@pytest.mark.parametrize("engine,marker", [
("unity", "Game_Data/Managed/Assembly-CSharp.dll"),
("unity", "Game_Data/il2cpp_data/Metadata/global-metadata.dat"),
("gamemaker", "data.win"), ("godot", "Game.pck"), ("rpgmaker", "Game.rgss3a")])
def test_unsupported_engines_fail_with_reason(tmp_path, engine, marker):
write(tmp_path / marker)
with pytest.raises(CompatibilityError) as exc:
detect(tmp_path)
assert exc.value.engine == engine
assert exc.value.report()["issues"][0]["code"] == "unsupported_engine"
def test_naninovel_detection_ignores_migration_copies(tmp_path):
write(tmp_path / "Game_Data/Managed/Elringus.Naninovel.Runtime.dll")
write(tmp_path / "migration-staging/port/project.godot")
assert detect(tmp_path).engine == "naninovel"
def test_multiple_same_engine_games_are_not_merged(tmp_path):
write(tmp_path / "one/game/script.rpy", 'label start:\n "one"')
write(tmp_path / "two/game/script.rpy", 'label start:\n "two"')
with pytest.raises(CompatibilityError, match="несколько игр"):
detect(tmp_path)
def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
source = 'screen unused():\n text "UI"\nlabel start:\n "Hello"\n return\n'
write(tmp_path / "game/script.rpy", source)
assert inspect_directory(tmp_path)["compatible"]
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
with pytest.raises(CompatibilityError, match="call screen"):
inspect_directory(tmp_path)
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n pass\nlabel start:\n "Hi"', "init python"),
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
('label start:\n jump missing', "missing"),
])
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
data = script(source)
audit_ir(data)
with pytest.raises(CompatibilityError, match=reason):
require_supported_scripts(data)
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
def media(*args, **kwargs):
pytest.fail("Media must not be copied/converted for an unsupported game")
monkeypatch.setattr("app.converter.pipeline.shutil.copytree", media)
monkeypatch.setattr("app.converter.pipeline.assets.convert", media)
with pytest.raises(CompatibilityError):
convert_archive(tmp_path / "source", "source", tmp_path / "work", lambda _: None)
assert (tmp_path / "source/game/script.rpy").exists()
def test_archive_probe_does_not_extract_anything(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/data.win", b"placeholder")
with pytest.raises(CompatibilityError) as exc:
probe_archive(source, source.name)
assert exc.value.engine == "gamemaker"
assert list(tmp_path.iterdir()) == [source]
def test_archive_probe_ignores_engine_runtime_scripts(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/Game_Data/Managed/Elringus.Naninovel.Runtime.dll", b"")
archive.writestr("Game/renpy/common/script.rpy", b"")
assert probe_archive(source, source.name)["engine"] == "naninovel"
def test_script_preflight_does_not_unpack_loose_media(tmp_path):
from app.converter.archive import extract
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/game/script.rpy", 'label start:\n call screen map')
archive.writestr("Game/game/images/large.png", b"media")
destination = tmp_path / "scripts"
extract(source, source.name, destination, only_scripts=True)
assert (destination / "Game/game/script.rpy").exists()
assert not (destination / "Game/game/images").exists()
def test_batch_prefers_unpacked_duplicate_and_reports_executables(tmp_path):
(tmp_path / "Game").mkdir()
write(tmp_path / "Game.zip")
write(tmp_path / "Other.exe")
assert [p.name for p in entries(tmp_path)] == ["Game", "Other.exe"]
assert check(tmp_path / "Other.exe")["issues"][0]["code"] == "not_distribution"
def test_naninovel_decodes_line_order_and_dynamic_parameters():
tree = {"m_Name": "Opening", "lines": [{"id": 1}], "references": {"records": [
{"type": {"class": "PrintText"}, "data": {
"Text": {"hasValue": False, "value": "", "dynamicValue": {"ValueText": "Score: {score}"}},
"playbackSpot": {"lineIndex": 3}}},
{"type": {"class": "GenericTextScriptLine"}, "data": {
"lineIndex": 3, "lineHash": "original", "inlinedCommands": [{"id": 0}]}}]}}
decoded = decode_script(tree)
assert decoded["lines"][0]["commands"][0]["args"]["Text"] == {"template": "Score: {score}"}
tree["lines"][0]["id"] = -1
with pytest.raises(ValueError, match="ссылка"):
decode_script(tree)
def test_naninovel_compiles_dialogue_branches_choices_and_expressions():
original = package(command("SetCustomVariable", Expression="score=2"),
command("BeginIf", Expression="score>=1 && true"),
command("PrintText", Text={"template": "Score: {score}"}, AuthorId="Alice"),
command("Else"), command("PrintText", Text="Low"), command("EndIf"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
data, entry, expressions = compile_package(original)
body = data.modules["Opening.nani"][0]["body"]
assert entry == "nani:Opening"
assert body[0]["code"][0]["v"]["k"] == "nani"
assert len(body[1]["branches"]) == 2
assert body[2]["op"] == "nani_add_choice" and body[3]["op"] == "nani_menu"
assert expressions == {"score": ["name", "score"]}
@pytest.mark.parametrize("commands,reason", [
([command("Spawn", Path="Minigame")], "Spawn"),
([command("PrintText", Text="Text", Unexpected=True)], "Unexpected"),
([command("SetCustomVariable", Expression="x=CustomFunction()")], "CustomFunction"),
([command("SetCustomVariable", Expression={"template": "x={other}"})], "Динамический"),
([command("Stop")], "внешнего интерфейса"),
([command("Goto", Path="Missing")], "цель"),
([command("BeginIf", Expression="true")], "Не закрыт"),
])
def test_naninovel_unknown_commands_functions_and_fields_are_fatal(commands, reason):
with pytest.raises(CompatibilityError, match=reason):
compile_package(package(*commands))
def test_naninovel_expression_parser_never_accepts_code():
ast = parse('score>1 ? "Yes" : "No"')
assert ast[0] == "if"
assert parse('"Привет\\n"')[1] == "Привет\n"
assert assignment("score=score+1")["v"]["k"] == "nani"
for source in ('__import__("os")', 'x.constructor()', 'x[0]', 'x; other()', 'x ? 1'):
with pytest.raises(ValueError):
parse(source)
def test_naninovel_resources_keep_keys_delimiters_and_context():
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
assert [r["text"] for r in records.values()] == ["Title", "Description"]
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
with pytest.raises(ValueError, match="Повторяющийся"):
text_records("DefaultUI", "Key:A\nKey:B", "UI")
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
resource={"delimiters": "|\r\n"})
validate_nani_translation(info, "<b>Привет {name}</b>")
for invalid in ("Привет {name}", "<b>Привет {other}</b>", "<b>Привет {name}|test</b>"):
with pytest.raises(ValueError):
validate_nani_translation(info, invalid)
def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkeypatch):
from app.blobs import read_bytes
from app.converter.naninovel import convert_naninovel
from app.translation import apply_changes, locale_tables, text_index
from app.mods import document, validate
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
original = package(command("SetCustomVariable", Expression="score=3"),
command("PrintText", Text="Hello {score}"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
original.texts = text_records("Tips", "Help:Title|Description\n", "Tips")
monkeypatch.setattr("app.converter.naninovel.read_package", lambda *args, **kwargs: original)
try:
result = convert_naninovel(tmp_path, tmp_path / "work", lambda _: None)
game = json.loads(read_bytes(result.manifest["files"]["game.json"]["hash"]))
assert game["engine"] == "naninovel" and game["entry"] == "nani:Opening"
index = text_index(result.manifest)
assert len(index.lines) == 4 # dialogue, choice, two independently addressed Tips parts
line = next(info for info in index.lines if info.source == "Hello {score}")
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет {score}"}})
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Привет {score}"
with pytest.raises(ValueError, match="выражения"):
apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет"}})
validate(document(translated), translated)
finally:
get_settings.cache_clear()
def test_unity_codec_restores_global_hooks_after_error():
from UnityPy.helpers import TypeTreeHelper as helper
from app.converter.unity_scripts import ScriptCodec
codec = ScriptCodec.__new__(ScriptCodec)
before = (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node)
with pytest.raises(RuntimeError):
with codec.active():
raise RuntimeError("broken object")
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
from app.routers import versions
from app.uploads import upload_path
from fastapi import HTTPException
source = tmp_path / "unsupported.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("data.win", b"game")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
states = []
class DB:
version = None
def add(self, version):
version.id = 123
self.version = version
async def commit(self):
states.append(self.version.status)
async def delete(self, version):
assert version.status != "queued"
async def game(*_):
return SimpleNamespace(id=1)
async def stream():
yield source.read_bytes()
monkeypatch.setattr(versions, "get_game", game)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(versions.upload_version(1, SimpleNamespace(stream=stream), SimpleNamespace(id=1), DB(), source.name))
assert exc.value.status_code == 422
assert "gamemaker" in exc.value.detail
assert "queued" not in states
assert not upload_path(123).exists()
finally:
get_settings.cache_clear()
+27
View File
@@ -0,0 +1,27 @@
import asyncio
import os
import pytest
from app.config import get_settings
from app.maintenance import acquire, release
@pytest.mark.skipif(os.name == "nt", reason="Production shared flock semantics are POSIX")
def test_gc_excludes_active_writers(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
async def check():
writer = acquire()
gc = asyncio.create_task(asyncio.to_thread(acquire, True))
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(gc), .1)
finally:
release(writer)
maintenance = await asyncio.wait_for(gc, 3)
release(maintenance)
try:
asyncio.run(check())
finally:
get_settings.cache_clear()
+163
View File
@@ -0,0 +1,163 @@
"""Integration against PostgreSQL in a temporary schema, never touches library rows."""
import asyncio
import copy
import os
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as database
from app.blobs import put_bytes
from app.db import Base
from app.main import app
from app.models import Game, GameVersion, Revision, Role, User
from app.permissions import ALL
from app.security import COOKIE_NAME, create_token
from tests.test_mods import pg_manifest # noqa: F401
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 to use isolated PostgreSQL schema")
def test_complete_mod_workflow(pg_manifest, monkeypatch):
async def scenario():
schema = "test_mods_" + uuid4().hex
engine = create_async_engine(database.engine.url, connect_args={"server_settings": {"search_path": schema}})
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(database, "SessionLocal", factory)
async with engine.begin() as conn:
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
await conn.run_sync(Base.metadata.create_all)
try:
async with factory() as db:
admin = Role(name="admin", permissions=ALL)
reader = Role(name="reader", permissions=["games.play"])
db.add_all([admin, reader])
await db.flush()
user = User(username="author", password_hash="unused", role_id=admin.id, must_change_password=False)
player = User(username="player", password_hash="unused", role_id=reader.id, must_change_password=False)
db.add_all([user, player])
await db.flush()
game = Game(title="Integration", created_by_id=user.id)
db.add(game)
await db.flush()
original = GameVersion(game_id=game.id, owner_id=user.id, name="Original", status="ready", manifest=pg_manifest)
db.add(original)
await db.commit()
original_id, user_id, player_id = original.id, user.id, player.id
original_revision = original.revision_id
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
hash = next(iter(pg_manifest["files"].values()))["hash"]
assert (await client.get(f"/blobs/{hash}")).status_code == 401
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
assert (await client.get(f"/blobs/{hash}")).status_code == 200
async def fork(name):
response = await client.post(f"/api/versions/{original_id}/fork", json={"name": name})
assert response.status_code == 201, response.text
return response.json()
a, b = await fork("A"), await fork("B")
assert a["base_revision_id"] == original_revision
edit = (await client.get(f'/api/versions/{a["id"]}/editor')).json()
doc = edit["document"]
script_path = next(p for p, m in doc["scripts"].items() if m["labels"])
first = doc["scripts"][script_path]["labels"][0]["body"]
first.insert(0, {"id": str(uuid4()), "op": "say", "tid": "integration_insert", "what": "Mod insertion"})
response = await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})
assert response.status_code == 200, response.text
a_revision = response.json()["revision"]
assert a_revision != edit["revision"]
assert (await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})).status_code == 409
# Draft-only media is unavailable to another player, even with the exact hash.
manifest = (await client.get(f'/api/versions/{a["id"]}/manifest')).json()
draft_hash = manifest["files"][script_path]["hash"]
client.cookies.set(COOKIE_NAME, create_token(player_id, 0))
assert (await client.get(f"/blobs/{draft_hash}")).status_code == 404
assert (await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')).status_code == 404
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')
assert response.status_code == 200 and "private" in response.headers["cache-control"]
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}', headers={"If-None-Match": response.headers["etag"]})
assert response.status_code == 304
body = {"target_id": b["id"], "source_revision": a_revision, "target_revision": b["revision_id"]}
preview = await client.post(f'/api/versions/{a["id"]}/merge', json=body)
assert preview.status_code == 200 and not preview.json()["conflicts"], preview.text
result = await client.post(f'/api/versions/{a["id"]}/merge', json={**body, "apply": True})
assert result.status_code == 200, result.text
result_id = result.json()["id"]
revisions = (await client.get(f'/api/versions/{result_id}/revisions')).json()
assert {a_revision, b["revision_id"]} <= {r["id"] for r in revisions}
# A new independently imported original has unrelated IDs.
from app import mods
from app.converter.locales import walk
upstream = mods.prepare(mods.document(pg_manifest))
for module in upstream["scripts"].values():
for label in module["labels"]:
label["id"] = str(uuid4())
for n in walk(label["body"]):
n["id"] = str(uuid4())
for child in n.get("branches", []) + n.get("items", []):
child["id"] = str(uuid4())
upstream["scripts"][script_path]["labels"][0]["body"].append(
{"id": str(uuid4()), "op": "say", "tid": "upstream_new", "what": "Upstream addition"})
new_manifest = mods.store(upstream, pg_manifest)
async with factory() as db:
new_original = GameVersion(game_id=game.id, owner_id=user_id, name="New original", status="ready", manifest=new_manifest)
db.add(new_original)
await db.commit()
rebase_body = {"target_id": new_original.id, "source_revision": a_revision,
"target_revision": new_original.revision_id, "mode": "rebase"}
response = await client.post(f'/api/versions/{a["id"]}/merge', json=rebase_body)
assert response.status_code == 200 and not response.json()["conflicts"], response.text
response = await client.post(f'/api/versions/{a["id"]}/merge', json={**rebase_body, "apply": True})
assert response.status_code == 200, response.text
rebased = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
rebased_id = response.json()["id"]
lines = [n.get("what") for m in rebased["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
assert "Mod insertion" in lines and "Upstream addition" in lines
# Repeating the transfer must use the target's canonical identity,
# rather than interpreting every original node as an inserted mod node.
response = await client.post(f'/api/versions/{rebased_id}/merge', json={
"target_id": new_original.id, "source_revision": rebased["revision"],
"target_revision": new_original.revision_id, "mode": "rebase", "apply": True})
assert response.status_code == 200, response.text
repeated = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
repeated_lines = [n.get("what") for m in repeated["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
assert repeated_lines.count("Mod insertion") == 1
assert repeated_lines.count("Upstream addition") == 1
# A real replacement is converted, and a stale upload is refused.
import io
from PIL import Image
image = io.BytesIO()
Image.new("RGB", (32, 32), "red").save(image, "PNG")
asset_path = next(p for p in pg_manifest["files"] if p.endswith(".webp"))
response = await client.put(f'/api/versions/{a["id"]}/asset',
params={"path": asset_path, "revision": a_revision, "filename": "new.png"}, content=image.getvalue())
assert response.status_code == 200, response.text
replaced = response.json()
assert replaced["files"][asset_path]["hash"] != pg_manifest["files"][asset_path]["hash"]
a_revision = replaced["revision"]
# Base survives deletion of the parent version card.
assert (await client.delete(f"/api/versions/{original_id}")).status_code == 204
async with factory() as db:
assert await db.get(Revision, original_revision)
# Restore keeps a new revision and the old revision is still reachable.
response = await client.post(f'/api/versions/{a["id"]}/revisions/restore', json={"revision": a_revision, "restore": edit["revision"]})
assert response.status_code == 200, response.text
# GC preserves current/base/history; collects a genuinely unreferenced blob.
from app import gc
monkeypatch.setattr(gc, "SessionLocal", factory)
garbage_hash, _ = put_bytes(b"unreferenced-integration", ".bin")
report = await gc.collect(False)
assert report["files"] >= 1
from app.blobs import blob_path
assert blob_path(garbage_hash).exists()
await gc.collect(True)
assert not blob_path(garbage_hash).exists()
assert blob_path(draft_hash).exists() # retained ancestor
finally:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
await engine.dispose()
asyncio.run(scenario())
+114
View File
@@ -0,0 +1,114 @@
import copy
from uuid import uuid4
import pytest
from app import mods
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.translation import blob_json, file_json
from tests.make_sample import build
@pytest.fixture
def pg_manifest(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
blob_json.cache_clear()
yield convert_archive(build(tmp_path / "sample.zip"), "sample.zip", tmp_path / "work", lambda _: None).manifest
blob_json.cache_clear()
get_settings.cache_clear()
def node(id, text):
return {"id": id, "op": "say", "tid": "t_" + id, "what": text}
def merge(base, left, right, choices=None):
conflicts = []
out = mods.merge_value(base, left, right, "", conflicts, choices or {})
return out, conflicts
def test_editor_roundtrip_validation_and_anchors(pg_manifest):
doc = mods.prepare(mods.document(pg_manifest))
assert doc == mods.prepare(mods.document(pg_manifest))
mods.validate(doc, pg_manifest)
for module in doc["scripts"].values():
if module["labels"]:
module["labels"][0]["body"].insert(0, node(str(uuid4()), "Inserted"))
updated = mods.store(doc, pg_manifest)
assert mods.comparable(mods.document(updated))["scripts"] == mods.comparable(doc)["scripts"]
assert file_json(pg_manifest, "game.json") == doc["game"]
damaged = copy.deepcopy(doc)
first = next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]
first["body"].append(copy.deepcopy(first["body"][0]))
with pytest.raises(ValueError, match="ID"):
mods.validate(damaged, pg_manifest)
damaged = copy.deepcopy(doc)
next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]["body"].append({"op": "jump", "id": "bad", "target": "missing_label"})
with pytest.raises(ValueError, match="метку"):
mods.validate(damaged, pg_manifest)
def test_merge_disjoint_fields_and_insertions():
base = [node("a", "A"), node("b", "B"), node("c", "C")]
left = [node("a", "Changed"), node("x", "X"), node("b", "B"), node("c", "C")]
right = [node("a", "A"), node("b", "B"), node("y", "Y"), node("c", "Changed too")]
result, conflicts = merge(base, left, right)
assert not conflicts
assert [n["id"] for n in result] == ["a", "x", "b", "y", "c"]
assert result[0]["what"] == "Changed" and result[-1]["what"] == "Changed too"
def test_conflicting_edit_delete_and_order():
base = [node("a", "A"), node("b", "B")]
left = [node("a", "LEFT"), node("b", "B")]
right = [node("b", "B")]
_, conflicts = merge(base, left, right)
assert conflicts[0]["path"] == "/@a" and conflicts[0]["rightDeleted"]
result, conflicts = merge(base, left, right, {"/@a": "right"})
assert result == right and not conflicts
_, conflicts = merge(base, [base[0], node("x", "X"), base[1]], [base[0], node("y", "Y"), base[1]])
assert conflicts[0]["path"] == "/$order"
def test_asset_conflict_keeps_hash_size_atomic():
base = {"hash": "a", "size": 10}
left = {"hash": "b", "size": 10}
right = {"hash": "c", "size": 20}
assert merge(base, left, right, {"": "left"}) == (left, [])
def test_malformed_menu_condition_rejected(pg_manifest):
doc = mods.prepare(mods.document(pg_manifest))
from app.converter.locales import walk
menu = next(n for m in doc["scripts"].values() for l in m["labels"] for n in walk(l["body"]) if n["op"] == "menu")
menu["items"][0]["if"] = {"k": "run-javascript"}
with pytest.raises(ValueError, match="выражение"):
mods.validate(doc, pg_manifest)
def test_optional_nani_expression_contract():
from app.pgvn import expression
expression({"k": "nani", "ast": ["bin", "+", ["name", "score"], ["const", 1]]})
with pytest.raises(ValueError):
expression({"k": "nani", "ast": ["call", "eval", [["const", "alert(1)"]]]})
def test_rebase_does_not_trust_positional_ids():
def doc(nodes):
return {"game": {}, "scripts": {"script/a.json": {"labels": [{"id": "label", "name": "start", "body": nodes}]}}}
base = doc([node("1", "A"), node("2", "B")])
target = doc([node("1", "Inserted"), node("2", "A"), node("3", "B")])
aligned, unmatched = mods.align(base, target)
assert not unmatched
nodes = aligned["scripts"]["script/a.json"]["labels"][0]["body"]
assert nodes[0]["id"] not in ("1", "2")
assert [n["id"] for n in nodes[1:]] == ["1", "2"]
assert aligned == mods.align(base, target)[0]
target["scripts"]["script/a.json"]["labels"][0]["body"][1]["what"] = "Updated A"
_, unmatched = mods.align(base, target)
assert unmatched[0]["id"] == "1"
aligned, _ = mods.align(base, target, {"1": "2"})
assert aligned["scripts"]["script/a.json"]["labels"][0]["body"][1]["id"] == "1"
+46
View File
@@ -0,0 +1,46 @@
import asyncio
import json
import os
import subprocess
import sys
from uuid import uuid4
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import engine as configured_engine
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 for isolated database migration test")
def test_upgrade_existing_versions_and_downgrade():
async def check():
name = "migration_test_" + uuid4().hex
admin = create_async_engine(configured_engine.url, isolation_level="AUTOCOMMIT")
test_url = configured_engine.url.set(database=name)
env = {**os.environ, "DATABASE_URL": test_url.render_as_string(hide_password=False)}
async def alembic(*args):
proc = await asyncio.to_thread(subprocess.run, [sys.executable, "-m", "alembic", *args], env=env, capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
async with admin.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{name}"'))
test_engine = create_async_engine(test_url)
try:
await alembic("upgrade", "0002")
async with test_engine.begin() as conn:
await conn.execute(text("INSERT INTO games(id,title,description,tags,engine) VALUES(1,'Old','','{}','renpy')"))
await conn.execute(text("INSERT INTO game_versions(id,game_id,name,description,status,progress,size_bytes,manifest) VALUES(1,1,'Original','','ready','',0,CAST(:m AS jsonb)),(2,1,'Legacy mod','','ready','',0,CAST(:m AS jsonb))"), {"m": json.dumps({"format": "pgvn/1", "files": {}})})
await conn.execute(text("UPDATE game_versions SET parent_id=1,kind='mod' WHERE id=2"))
await alembic("upgrade", "head")
async with test_engine.connect() as conn:
rows = (await conn.execute(text("SELECT revision_id,base_revision_id FROM game_versions"))).all()
assert len(rows) == 2 and all(r[0] and r[1] is None for r in rows)
assert await conn.scalar(text("SELECT count(*) FROM revisions")) == 2
await alembic("downgrade", "0002")
await alembic("upgrade", "head")
finally:
await test_engine.dispose()
async with admin.connect() as conn:
await conn.execute(text(f'DROP DATABASE "{name}" WITH (FORCE)'))
await admin.dispose()
asyncio.run(check())
+13
View File
@@ -56,6 +56,19 @@ def test_merge_languages_keeps_target_untouched(manifest):
assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {}) assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {})
def test_three_way_publication_keeps_independent_lines(manifest):
a, b = [line.tid for line in tr.text_index(manifest).lines[:2]]
left = tr.apply_changes(manifest, "de", lines={a: {"t": "One"}})
right = tr.apply_changes(manifest, "de", lines={b: {"t": "Two"}})
merged, conflicts = tr.merge_translation(manifest, left, right)
assert not conflicts
lines = tr.locale_tables(merged, "de")[0]
assert lines[a]["t"] == "One" and lines[b]["t"] == "Two"
conflict = tr.apply_changes(manifest, "de", lines={a: {"t": "Other"}})
_, conflicts = tr.merge_translation(manifest, left, conflict)
assert conflicts and any(a in c["path"] for c in conflicts)
def test_po_and_csv_roundtrip(manifest): def test_po_and_csv_roundtrip(manifest):
tid = tr.text_index(manifest).lines[0].tid tid = tr.text_index(manifest).lines[0].tid
m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}}, m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}},
+15
View File
@@ -54,5 +54,20 @@ services:
depends_on: depends_on:
- backend - backend
gc:
build: ./backend
profiles: [maintenance]
entrypoint: [python, -m, app.gc]
command: []
env_file: ${ENV_FILE:-.env}
environment:
DATABASE_URL: postgresql+asyncpg://pgames:${POSTGRES_PASSWORD:-pgames}@db:5432/pgames
DATA_DIR: /data
volumes:
- ${DATA_PATH:-./data}:/data
depends_on:
backend:
condition: service_healthy
volumes: volumes:
db: db:
+10 -6
View File
@@ -12,7 +12,7 @@ Proxmox host ── Caddy (TLS, домен) ──► http://<IP VM>:8080
│ │
VM: docker compose│ VM: docker compose│
┌─────────────────────────────────────┴───────────┐ ┌─────────────────────────────────────┴───────────┐
│ web (nginx) : SPA, /api → backend, /blobs → том │ │ web (nginx) : SPA, API → backend, media internal │
│ backend : FastAPI (uvicorn) │ │ backend : FastAPI (uvicorn) │
│ worker : конвертер, тот же образ │ │ worker : конвертер, тот же образ │
│ db : PostgreSQL 16 │ │ db : PostgreSQL 16 │
@@ -56,8 +56,9 @@ LOCKED`), Redis не нужен.
### Хранилище блобов ### Хранилище блобов
`data/blobs/ab/cd/<sha256>` — неизменяемые файлы, отдаются nginx с `data/blobs/ab/cd/<sha256>` — неизменяемые файлы. Доступ проверяет backend,
`Cache-Control: immutable`. Манифест версии: затем nginx отдаёт файл через закрытый internal location с `private, no-cache`.
Манифест версии:
```json ```json
{ "files": { "game.json": {"hash": "…", "size": 1234}, { "files": { "game.json": {"hash": "…", "size": 1234},
@@ -103,7 +104,9 @@ LOCKED`), Redis не нужен.
Скрипт-модуль (`script/*.json`) — упорядоченный список меток, у каждой тело Скрипт-модуль (`script/*.json`) — упорядоченный список меток, у каждой тело
из узлов-операторов. Структура остаётся деревом (близко к исходнику, удобно из узлов-операторов. Структура остаётся деревом (близко к исходнику, удобно
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы редактировать), состояние плеера — стек `{label, path}` где `path` — индексы
внутри вложенных блоков, так что сохранения стабильны и компактны. внутри вложенных блоков. Новые сохранения дополнительно содержат ревизию и ID
узлов для проверки позиции при изменении скрипта; совместимость переменных
после изменения игровой логики требует отдельной проверки.
У каждого узла есть постоянный `id`, у реплик — ещё `tid` для перевода. У каждого узла есть постоянный `id`, у реплик — ещё `tid` для перевода.
Переводы лежат отдельно, в `locales/<язык>/…` (см. [LOCALIZATION.md](LOCALIZATION.md)). Переводы лежат отдельно, в `locales/<язык>/…` (см. [LOCALIZATION.md](LOCALIZATION.md)).
@@ -130,7 +133,8 @@ LOCKED`), Redis не нужен.
## Дорожная карта ## Дорожная карта
* Веб-редактор форка: правка скриптов и замена ассетов (манифест уже готов). * Реализованы редактор PGVN-форка, замена ассетов, ревизии, трёхстороннее слияние
и перенос на новый оригинал — см. [MODDING.md](MODDING.md).
* Поддержка screens/ATL-трансформаций в IR. * Поддержка screens/ATL-трансформаций в IR.
* Другие движки (TyranoBuilder, RPG Maker MV) через тот же IR. * Другие движки (TyranoBuilder, RPG Maker MV) через тот же IR.
* Сборщик мусора блобов по расписанию. * Сборщик мусора доступен как `python -m app.gc`, включая периодический режим.
+155
View File
@@ -0,0 +1,155 @@
# Моды, ревизии и обслуживание
## Работа с модом
1. На карточке готовой версии нажмите «Форк». Откроется собственный черновик мода.
2. Выберите модуль и метку. Редактор позволяет менять реплики и переходы, добавлять,
удалять и перемещать команды, редактировать условия и вложенные варианты выбора.
Дополнительные параметры выражений и команд редактируются как PGVN JSON AST.
Это редактор PGVN, а не редактор исходного Ren'Py.
3. Сохраните мод. Сервер проверит IDs, метки, ссылки, структуру команд и точку входа;
позиции вложенных меток (`anchors`) будут пересчитаны.
4. Откройте предпросмотр всей игры или отдельной команды. Запускается последнее
сохранённое состояние. Изменение переменных при запуске с середины не воспроизводится.
5. В «Замене ассетов» загрузите файл соответствующего типа. Изображения, аудио и видео
проходят конвертацию, шрифты проверяются по сигнатуре. Лимит одного файла — 512 МБ.
Логический путь остаётся прежним; хэш меняется только в этом моде.
6. Посмотрите структурные отличия от базы и историю. Откат создаёт новую ревизию;
предыдущие состояния остаются доступны.
Сохранение отправляет ожидаемую ревизию. Если другая вкладка успела изменить мод,
сервер отвечает 409; правки в открытом редакторе не сбрасываются. Перед закрытием
страницы с несохранёнными изменениями браузер показывает предупреждение.
Редактируются собственные черновики `kind=mod` при наличии `versions.fork`.
Публикация требует `versions.publish`. Опубликованную версию сначала форкают.
## Слияние и перенос
В редакторе выберите другую готовую версию той же игры и операцию:
- «Объединить моды»: трёхстороннее слияние относительно ближайшего общего предка
в графе ревизий. Независимые поля и вставки в разных местах объединяются.
- «Перенести на новый оригинал»: база — зафиксированный оригинал мода. Новый оригинал
сопоставляется с базой по уникальному содержимому команд внутри одноимённых меток
и модулей. Совпадение порядкового номера или старого короткого ID не является доказательством.
Неоднозначные узлы показываются для ручного сопоставления. Без соответствия узел
считается удалённым в новом оригинале. Если мод менял удалённый узел, возникает конфликт.
Автоматическое сопоставление переименованных модулей/меток не выполняется: такие
случаи требуют ручного переноса правок в редакторе.
Разные изменения одного поля, удаление изменённого узла и неоднозначный порядок
вставок требуют выбора варианта. При замене одного ассета двумя модами выбирается
файл целиком. После выбора можно повторить проверку.
Результат всегда новый черновик. Перед применением сервер повторно проверяет обе
исходные ревизии. Ревизия результата хранит обоих родителей. При переносе сопоставленные
узлы и переводы принимают канонические ID нового оригинала; новые команды самого мода
сохраняют свои ID. Поэтому новый оригинал становится корректной базой следующего переноса.
Публикация перевода также использует зафиксированную базу и объединяет отдельные записи
языка: разные строки не перезаписывают друг друга. Несовместимые изменения одной записи
блокируют публикацию с указанием пути конфликта. Их нужно согласовать в редакторе перевода.
## Ревизии и совместимость
`game_versions.revision_id` указывает на неизменяемый снимок `revisions`.
`base_revision_id` фиксирует базу форка. Запись любого нового манифеста — через
редактор, перевод, публикацию, откат или импорт — автоматически создаёт снимок.
Снимок содержит автора, время, манифест и родителей и не зависит от удаления
карточки родительской версии. Новые узлы, метки и ветки получают UUID; существующие
ID не пересоздаются при обычной правке.
Миграция `0003` создаёт текущие снимки существующих версий. Истинную базу старого
форка восстановить нельзя: его `base_revision_id` остаётся пустым. Для надёжного
переноса старого мода создайте новый форк нужного оригинала и перенесите правки.
Автоматическое слияние таких старых независимых веток намеренно блокируется.
Новые сохранения плеера содержат ревизию и ID текущих узлов/кадров вызовов. При
изменении ревизии плеер ищет позиции по ID и просит подтвердить попытку загрузки:
совместимость переменных и новой игровой логики автоматически не доказывается.
Если позиция не найдена, загрузка блокируется. Старые сохранения без ревизии
продолжают читаться в прежнем формате с индексными путями после предупреждения.
Интерпретатор блокирует доступ к служебным свойствам JavaScript (`constructor`,
`prototype`, `__proto__`), изменение прототипов и доступ к свойствам нативных
функций из игрового AST. Это проверяется регрессионными тестами.
## Доступ к файлам
Плеер использует `/api/versions/{id}/files/{hash}`. Сервер проверяет действующую
сессию, `games.play`, видимость версии и наличие хэша в её манифесте. Совместимый
маршрут `/blobs/{hash}` также проверяет доступ через видимые версии/обложки.
Сам по себе известный хэш прав не даёт.
В Docker файл отправляет nginx через `X-Accel-Redirect` и `internal` location.
Прямое обращение к `/_protected_blobs/` запрещено. Без nginx работает FileResponse.
Ответы имеют `private, no-cache`, ETag и `nosniff`; проверка прав предшествует 304.
Уже скачанные файлы отозвать нельзя. Старые публичные кэшированные ответы до
обновления могут оставаться у клиентов до истечения их прежнего срока кэширования.
## GC
Предварительный отчёт, без удаления:
```sh
docker compose --profile maintenance run --rm gc
```
Удаление недостижимых блобов и осиротевших записей ревизий:
```sh
docker compose --profile maintenance run --rm gc --execute
```
Периодический запуск отдельным процессом (например, раз в сутки):
```sh
docker compose --profile maintenance run -d --name pgames-gc gc --execute --interval 86400
```
GC берёт эксклюзивную файловую блокировку общего `DATA_DIR/maintenance.lock`.
API-записи и конвертация удерживают совместимые блокировки от начала работы до
коммита БД. Поэтому GC дожидается активных операций и не удаляет ни новые блобы,
ни старые блобы, повторно использованные ещё не зафиксированным манифестом.
На Windows блокировки сериализуют доступ; production-контейнеры используют POSIX flock.
Корни: текущие манифесты всех версий, базовые и текущие ревизии с предками,
`version_history`, обложки. Черновики и история сохраняются. Независимые снимки
удалённой версии, не нужные другим веткам, могут быть удалены. История живой
версии не сокращается автоматически. При отсутствующей ревизии или живом блобе
сборка мусора прерывается. Символические ссылки не обходятся.
Все процессы должны использовать один и тот же DATA_DIR на файловой системе,
поддерживающей согласованные блокировки. Ручные записи в БД/блобы и старые
контейнеры, не использующие блокировки, несовместимы с одновременным GC.
При обновлении остановите старые backend/worker перед запуском нового GC.
## Сборка и проверки
unrpyc закреплён полным SHA в `backend/Dockerfile`; сборка проверяет HEAD.
Отчёт импорта содержит `converterVersion` и `unrpycRevision`. Обновление SHA
должно сопровождаться проверкой реальных `.rpyc` нужных версий Ren'Py.
`*.tsbuildinfo` исключены из Git.
```sh
cd backend
pip install -r requirements-dev.txt
python -m pytest
# Дополнительно: PostgreSQL, отдельные временные schema/database, библиотека не меняется
TEST_POSTGRES=1 python -m pytest
cd ../frontend
npm run build
```
Интеграционные тесты требуют PostgreSQL-пользователя с правами CREATE DATABASE
и CREATE SCHEMA. Они удаляют только созданные ими временные базы/схемы.
## Другие движки
Unity, GameMaker (FF692) и Godot (Beguiled) остаются отдельным направлением.
Сначала нужны доступные экземпляры игр и исследование способа хранения сценария:
извлечение ассетов само по себе не восстанавливает логику. Будущие адаптеры должны
выдавать PGVN и отчёт неподдерживаемых возможностей. Универсальные конвертеры
этих движков в текущий этап не входят.
+11 -4
View File
@@ -11,6 +11,7 @@ server {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-PGames-Accel 1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_request_buffering off; proxy_request_buffering off;
@@ -18,10 +19,16 @@ server {
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
} }
# content-addressed blobs: /blobs/<sha256><ext> -> /data/blobs/ab/cd/<id> location /blobs/ {
location ~ "^/blobs/(([0-9a-f]{2})([0-9a-f]{2})[0-9a-f]{60}(\.[a-z0-9]{1,8})?)$" { proxy_pass http://backend:8000;
alias /data/blobs/$2/$3/$1; proxy_set_header X-PGames-Accel 1;
add_header Cache-Control "public, max-age=31536000, immutable"; }
location /_protected_blobs/ {
internal;
alias /data/blobs/;
add_header Cache-Control "private, no-cache" always;
add_header X-Content-Type-Options nosniff always;
types { types {
image/webp webp; image/webp webp;
image/png png; image/png png;
+2
View File
@@ -6,6 +6,7 @@ import { GamePage } from "./pages/GamePage";
import { Library } from "./pages/Library"; import { Library } from "./pages/Library";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame"; import { NewGame } from "./pages/NewGame";
import { ModEditor } from "./pages/ModEditor";
import { Play } from "./pages/Play"; import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles"; import { Roles } from "./pages/Roles";
import { TranslationEditor } from "./pages/TranslationEditor"; import { TranslationEditor } from "./pages/TranslationEditor";
@@ -112,6 +113,7 @@ export function App() {
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} /> <Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} /> <Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} /> <Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} /> <Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} /> <Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} /> <Route path="/account" element={<ChangeCredentials />} />
+2
View File
@@ -20,6 +20,8 @@ export interface Me extends User {
} }
export interface Version { export interface Version {
revision_id: string | null;
base_revision_id: string | null;
id: number; id: number;
game_id: number; game_id: number;
parent_id: number | null; parent_id: number | null;
+16 -2
View File
@@ -85,7 +85,10 @@ export function GamePage() {
text: "Форк делит с оригиналом все ассеты и почти не занимает места.", text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк", label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
}); });
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан"); if (name) void run(async () => {
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name });
navigate(`/versions/${fork.id}/edit`);
}, "Форк создан");
}; };
const translate = async (v: Version) => { const translate = async (v: Version) => {
@@ -275,6 +278,7 @@ function VersionRow(props: {
<div className="actions"> <div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>} {v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>} {v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? ( {v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link> <Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && ( ) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
@@ -307,7 +311,17 @@ function VersionRow(props: {
function Report({ report }: { report: Record<string, any> | null }) { function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>; if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></div>; if (report.error) return <div className="report">
<ErrorNote>{report.error}</ErrorNote>
{report.engine && <p className="muted">Движок: {report.engine}</p>}
{report.issues?.length > 0 && <details>
<summary>Причины остановки ({report.issues.length})</summary>
<ul>{report.issues.map((issue: any, index: number) => <li key={index}>
{issue.message}{issue.count > 1 ? ` · ${issue.count}×` : ""}
{issue.location && <span className="muted"> — {issue.location}</span>}
</li>)}</ul>
</details>}
</div>;
const stat = (value: string | number, label: string) => ( const stat = (value: string | number, label: string) => (
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div> <div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
); );
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
import { ErrorNote, Spinner } from "../ui/Bits";
import { useUi } from "../ui/Ui";
import "./mods.css";
type Obj = Record<string, any>;
interface Document { game: Obj; scripts: Record<string, { file: string; labels: Obj[] }> }
interface Editor { revision: string; baseRevision: string | null; document: Document;
files: Record<string, { hash: string; size: number }>; editable: boolean; changes: string[];
diff: { path: string; before: unknown; after: unknown }[] }
interface Conflict { path: string; base: unknown; left: unknown; right: unknown; leftDeleted: boolean; rightDeleted: boolean }
interface Unmatched { id: string; module: string; label: string; text: string; candidates: { id: string; text: string }[] }
interface Preview { conflicts: Conflict[]; unmatched: Unmatched[]; baseRevision: string }
function uid() {
return globalThis.crypto?.randomUUID?.() ?? "mod-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
function makeNode(op: string): Obj {
const id = uid();
const fields: Record<string, Obj> = {
say: { who: null, what: "Новая реплика", tid: "mod_" + id },
jump: { target: "start" }, call: { target: "start" },
scene: { img: null, at: [] }, show: { img: { name: ["bg", "room"] }, at: [] },
hide: { tag: "bg" }, pause: { d: { k: "const", v: 1 } },
if: { branches: [{ id: uid(), c: { k: "const", v: true }, body: [] }] },
while: { c: { k: "const", v: false }, body: [] },
menu: { items: [{ id: uid(), text: "Вариант", if: null, body: [] }] },
exec: { code: [] }, play: { ch: "music", files: [{ k: "const", v: "audio/music.ogg" }], loop: true },
stop: { ch: "music" }, window: { mode: "show" },
};
return { id, op, ...fields[op] };
}
function JsonField({ value, onChange, label }: { value: any; onChange: (v: any) => void; label: string }) {
const [text, setText] = useState(JSON.stringify(value ?? null, null, 2));
const [error, setError] = useState("");
useEffect(() => setText(JSON.stringify(value ?? null, null, 2)), [value]);
return <label className="mod-field">{label}<textarea value={text} rows={Math.min(12, text.split("\n").length + 1)}
aria-invalid={!!error} onChange={(e) => {
setText(e.target.value);
try { const v = JSON.parse(e.target.value); setError(""); e.target.setCustomValidity(""); onChange(v); }
catch { setError("Некорректный JSON"); e.target.setCustomValidity("Некорректный JSON"); }
}} />{error && <span role="alert">{error}</span>}</label>;
}
function Commands({ nodes, onChange, versionId }: { nodes: Obj[]; onChange: (n: Obj[]) => void; versionId: string }) {
const ui = useUi();
const [op, setOp] = useState("say");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const patch = (i: number, node: Obj) => onChange(nodes.map((n, k) => k === i ? node : n));
const move = (i: number, delta: number) => {
const next = [...nodes]; [next[i], next[i + delta]] = [next[i + delta], next[i]]; onChange(next);
};
return <div className="mod-commands">
{nodes.map((n, i) => <details className="mod-command" key={n.id} open={expanded.has(n.id)} onToggle={(e) => {
const open = e.currentTarget.open;
setExpanded((previous) => {
if (previous.has(n.id) === open) return previous;
const next = new Set(previous); if (open) next.add(n.id); else next.delete(n.id); return next;
});
}}>
<summary>{i + 1}. {n.op} {n.what ?? n.target ?? n.name ?? ""}</summary>
{expanded.has(n.id) && <>
<div className="actions">
<button type="button" className="btn btn-sm" disabled={i === 0} onClick={() => move(i, -1)}>Выше</button>
<button type="button" className="btn btn-sm" disabled={i === nodes.length - 1} onClick={() => move(i, 1)}>Ниже</button>
<button type="button" className="btn btn-sm" onClick={() => { const next = [...nodes]; next.splice(i + 1, 0, makeNode(op)); onChange(next); }}>Вставить после</button>
<button type="button" className="btn btn-sm btn-danger" onClick={async () => {
if (await ui.confirm({ title: "Удалить команду вместе с вложенными ветками?", danger: true })) onChange(nodes.filter((_, k) => i !== k));
}}>Удалить</button>
<Link className="btn btn-sm" target="_blank" to={`/play/${versionId}?at=${encodeURIComponent(n.id)}`}>Играть отсюда после сохранения</Link>
</div>
{n.op === "say" && <>
<label className="mod-field">Персонаж<input value={n.who ?? ""} onChange={(e) => patch(i, { ...n, who: e.target.value || null })} /></label>
<label className="mod-field">Реплика<textarea value={n.what} onChange={(e) => patch(i, { ...n, what: e.target.value })} /></label>
</>}
{typeof n.target === "string" && <label className="mod-field">Метка перехода<input value={n.target} onChange={(e) => patch(i, { ...n, target: e.target.value })} /></label>}
{(n.op === "scene" || n.op === "show") && <label className="mod-field">Имя изображения<input value={n.img?.name?.join(" ") ?? ""}
onChange={(e) => patch(i, { ...n, img: e.target.value ? { name: e.target.value.split(/\s+/) } : null })} /></label>}
{n.op === "while" && <JsonField label="Условие (PGVN)" value={n.c} onChange={(c) => patch(i, { ...n, c })} />}
{n.op === "while" && <Commands nodes={n.body} versionId={versionId} onChange={(body) => patch(i, { ...n, body })} />}
{(n.op === "menu" || n.op === "if") && (() => {
const key = n.op === "menu" ? "items" : "branches";
const condKey = n.op === "menu" ? "if" : "c";
return <div>{n[key].map((b: Obj, j: number) => <section className="mod-branch" key={b.id}>
{n.op === "menu" && <label className="mod-field">Вариант выбора<input value={b.text} onChange={(e) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, text: e.target.value } : x) })} /></label>}
<JsonField label="Условие (null — без условия)" value={b[condKey]} onChange={(c) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, [condKey]: c } : x) })} />
<Commands nodes={b.body} versionId={versionId} onChange={(body) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, body } : x) })} />
<button className="btn btn-sm btn-danger" type="button" onClick={async () => {
if (await ui.confirm({ title: "Удалить ветку?", danger: true })) patch(i, { ...n, [key]: n[key].filter((_: Obj, k: number) => k !== j) });
}}>Удалить ветку</button>
</section>)}<button className="btn btn-sm" type="button" onClick={() => patch(i, { ...n, [key]: [...n[key], { id: uid(), [condKey]: null, body: [], ...(n.op === "menu" ? { text: "Вариант" } : {}) }] })}>Добавить ветку</button></div>;
})()}
<details><summary>Другие параметры команды (PGVN)</summary><JsonField label="Параметры" value={Object.fromEntries(Object.entries(n).filter(([k]) => !["id", "tid", "op", "body", "items", "branches"].includes(k)))}
onChange={(fields) => { if (fields && !Array.isArray(fields) && typeof fields === "object") {
const keep = Object.fromEntries(Object.entries(n).filter(([k]) => ["id", "tid", "op", "body", "items", "branches"].includes(k)));
patch(i, { ...fields, ...keep });
} }} /></details></>}
</details>)}
<div className="actions"><select aria-label="Тип новой команды" value={op} onChange={(e) => setOp(e.target.value)}>
{["say", "scene", "show", "hide", "jump", "call", "return", "pause", "menu", "if", "while", "exec", "play", "stop", "window", "noop"].map((v) => <option key={v}>{v}</option>)}
</select><button className="btn btn-sm" type="button" onClick={() => onChange([...nodes, makeNode(op)])}>Добавить команду</button></div>
</div>;
}
export function ModEditor() {
const { versionId = "" } = useParams();
const navigate = useNavigate();
const ui = useUi();
const { can } = useAuth();
const [data, setData] = useState<Editor | null>(null);
const [version, setVersion] = useState<Version | null>(null);
const [versions, setVersions] = useState<Version[]>([]);
const [history, setHistory] = useState<{ id: string; created_at: string }[]>([]);
const [module, setModule] = useState("");
const [label, setLabel] = useState(0);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [target, setTarget] = useState("");
const [mode, setMode] = useState("merge");
const [preview, setPreview] = useState<Preview | null>(null);
const [choices, setChoices] = useState<Record<string, string>>({});
const [mapping, setMapping] = useState<Record<string, string>>({});
const load = async () => {
const [next, v, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<Version>(`/api/versions/${versionId}`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
const game = await api.get<GameDetail>(`/api/games/${v.game_id}`);
setData(next); setVersion(v); setHistory(revisions); setVersions(game.versions.filter((x) => x.id !== v.id && x.status === "ready"));
setModule((old) => next.document.scripts[old] ? old : Object.keys(next.document.scripts).find((p) => next.document.scripts[p].labels.length) ?? next.document.game.scripts[0]); setLabel(0); setDirty(false); setPreview(null);
setChoices({}); setMapping({}); setTarget(""); setError("");
};
useEffect(() => { setData(null); void load().catch((e) => setError(e.message)); }, [versionId]);
useEffect(() => {
const guard = (e: BeforeUnloadEvent) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } };
window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard);
}, [dirty]);
const run = async (fn: () => Promise<void>) => {
setBusy(true); setError(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
if (!data || !version) return <div className="page">{error ? <ErrorNote>{error}</ErrorNote> : <Spinner />}</div>;
const change = (document: Document) => { setData({ ...data, document }); setDirty(true); setPreview(null); };
const mod = data.document.scripts[module];
const selected = mod?.labels[label];
const setLabelValue = (value: Obj) => change({ ...data.document, scripts: { ...data.document.scripts,
[module]: { ...mod, labels: mod.labels.map((l, i) => i === label ? value : l) } } });
const merge = async (apply: boolean) => {
const t = versions.find((v) => v.id === Number(target));
if (!t) throw new Error("Выберите целевую версию");
const body = { target_id: t.id, source_revision: data.revision, target_revision: t.revision_id, mode, choices, mapping, apply,
name: `${version.name} + ${t.name}`.slice(0, 255) };
if (apply) {
const result = await api.post<{ id: number }>(`/api/versions/${versionId}/merge`, body);
navigate(`/versions/${result.id}/edit`);
} else setPreview(await api.post<Preview>(`/api/versions/${versionId}/merge`, body));
};
return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1>
<Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link>
<Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link>
</div>{error && <ErrorNote>{error}</ErrorNote>}
<p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p>
{!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>}
<form onSubmit={(e) => { e.preventDefault(); void run(async () => {
const result = await api.put<{ revision: string; files: Editor["files"] }>(`/api/versions/${versionId}/editor`, { revision: data.revision, document: data.document });
setData({ ...data, revision: result.revision, files: result.files }); setDirty(false); ui.toast("Мод сохранён", "ok");
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
setData(fresh); setHistory(revisions);
}); }}><fieldset disabled={busy || !data.editable}>
<div className="mod-toolbar"><label>Модуль<select value={module} onChange={(e) => { setModule(e.target.value); setLabel(0); }}>{Object.keys(data.document.scripts).map((p) => <option key={p}>{p}</option>)}</select></label>
<label>Метка<select value={label} onChange={(e) => setLabel(Number(e.target.value))}>{mod?.labels.map((l, i) => <option key={l.id} value={i}>{l.name}</option>)}</select></label>
<button className="btn" type="button" onClick={async () => {
const name = await ui.prompt({ title: "Новая метка", label: "Имя" });
if (name) { change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: [...mod.labels, { id: uid(), name, body: [] }] } } }); setLabel(mod.labels.length); }
}}>Добавить метку</button>
</div>
{selected && <><label className="mod-field">Имя метки<input value={selected.name} onChange={(e) => setLabelValue({ ...selected, name: e.target.value })} /></label>
<Commands nodes={selected.body} versionId={versionId} onChange={(body) => setLabelValue({ ...selected, body })} />
<button className="btn btn-danger" type="button" onClick={async () => {
if (await ui.confirm({ title: "Удалить метку со всеми командами?", danger: true })) {
change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: mod.labels.filter((_, i) => i !== label) } } }); setLabel(0);
}
}}>Удалить метку</button></>}
<details><summary>Настройки игры (PGVN)</summary><JsonField label="game.json" value={data.document.game} onChange={(game) => { if (game && typeof game === "object" && !Array.isArray(game)) change({ ...data.document, game }); }} /></details>
<button className="btn btn-primary" disabled={!dirty} type="submit">{busy ? "Сохранение…" : "Сохранить мод"}</button>
</fieldset></form>
<details><summary>Отличия от базы ({data.changes?.length ?? 0} файлов)</summary><ul>{data.changes?.map((p) => <li key={p}>{p}</li>)}</ul>
{data.diff.map((d, i) => <details key={i}><summary>{d.path}</summary><div className="mod-conflict-values"><div>Было<pre>{JSON.stringify(d.before, null, 2)}</pre></div><div>Стало<pre>{JSON.stringify(d.after, null, 2)}</pre></div></div></details>)}
<button className="btn btn-sm" disabled={dirty || busy} onClick={() => void run(load)}>Обновить отличия</button></details>
<details><summary>Замена ассетов</summary><p>Заменяется файл только в этом моде. Сначала сохраните скрипт.</p>
<div className="mod-assets">{Object.entries(data.files).filter(([p]) => !p.startsWith("script/") && !p.startsWith("locales/") && p !== "game.json").map(([path, f]) => <div key={path}>
<a href={`/api/versions/${versionId}/files/${f.hash}`} target="_blank" rel="noreferrer">{path}</a>
<input aria-label={`Заменить ${path}`} type="file" disabled={busy || dirty || !data.editable} onChange={(e) => {
const file = e.target.files?.[0]; e.target.value = "";
if (file) void run(async () => {
const result = await uploadRaw<{ revision: string; files: Editor["files"] }>("PUT", `/api/versions/${versionId}/asset?${new URLSearchParams({ path, revision: data.revision, filename: file.name })}`, file, () => {});
setData({ ...data, ...result }); ui.toast("Ассет заменён", "ok");
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
setData(fresh); setHistory(revisions);
});
}} /></div>)}</div>
</details>
<details><summary>История и откат</summary>{history.map((h) => <div className="actions" key={h.id}><span>{new Date(h.created_at).toLocaleString()} · {h.id.slice(0, 8)}</span>
<button className="btn btn-sm" disabled={busy || dirty || !data.editable || h.id === data.revision} onClick={() => void run(async () => {
if (await ui.confirm({ title: "Восстановить эту ревизию?", text: "Текущее состояние останется в истории." })) { await api.post(`/api/versions/${versionId}/revisions/restore`, { revision: data.revision, restore: h.id }); await load(); }
})}>Восстановить</button></div>)}</details>
{can("versions.fork") && version.kind === "mod" && <section><h2>Слияние и перенос</h2><p>Результат создаётся отдельным черновиком. Исходные версии сохраняются.</p>
<div className="mod-toolbar"><select aria-label="Операция" value={mode} onChange={(e) => { setMode(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="merge">Объединить моды</option><option value="rebase">Перенести на новый оригинал</option></select>
<select aria-label="Целевая версия" value={target} onChange={(e) => { setTarget(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="">Выберите версию</option>{versions.map((v) => <option value={v.id} key={v.id}>{v.name}</option>)}</select>
<button className="btn" disabled={busy || dirty || !target} onClick={() => void run(() => merge(false))}>Проверить совместимость</button></div>
{preview && <div><p>База {preview.baseRevision.slice(0, 8)}. Конфликтов: {preview.conflicts.length}.</p>
{preview.unmatched.length > 0 && <details open><summary>Не сопоставленные узлы ({preview.unmatched.length})</summary><p>Выберите соответствие для изменённых команд и повторите проверку. Без соответствия команда считается удалённой в новом оригинале; изменённые модом команды потребуют разрешения конфликта.</p>
{preview.unmatched.map((n) => <label className="mod-field" key={n.id}>{n.module} / {n.label}: {n.text}<select value={mapping[n.id] ?? ""} onChange={(e) => { const next = { ...mapping }; if (e.target.value) next[n.id] = e.target.value; else delete next[n.id]; setMapping(next); setChoices({}); }}><option value="">Нет соответствия</option>{n.candidates.map((c) => <option value={c.id} key={c.id}>{c.text} · {c.id.slice(0, 8)}</option>)}</select></label>)}</details>}
{preview.conflicts.map((c) => <section className="mod-conflict" key={c.path}><b>{c.path}</b><div className="mod-conflict-values">{(["base", "left", "right"] as const).map((side) => <div key={side}><p>{side === "base" ? "База" : side === "left" ? "Этот мод" : "Целевая версия"}</p><pre>{JSON.stringify(c[side], null, 2)}</pre></div>)}</div>
<select aria-label={`Решение ${c.path}`} value={choices[c.path] ?? ""} onChange={(e) => setChoices({ ...choices, [c.path]: e.target.value })}><option value="">Выберите решение</option><option value="left">Этот мод{c.leftDeleted ? " (удалить)" : ""}</option><option value="right">Целевая версия{c.rightDeleted ? " (удалить)" : ""}</option></select></section>)}
<button className="btn" disabled={busy || dirty} onClick={() => void run(() => merge(false))}>Повторить проверку с решениями</button>
<button className="btn btn-primary" disabled={busy || dirty || preview.conflicts.some((c) => !choices[c.path])} onClick={() => void run(() => merge(true))}>Создать черновик результата</button>
</div>}
</section>}
</div>;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function NewGame() {
return ( return (
<div className="page medium"> <div className="page medium">
<PageHeader title="Новая игра" <PageHeader title="Новая игра"
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." /> subtitle="Загрузите архив игры. Сервер определит движок и проверит сценарии. Неподдерживаемые игры будут отклонены с объяснением причины." />
<UploadProgress /> <UploadProgress />
<form className="card form" onSubmit={submit}> <form className="card form" onSubmit={submit}>
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`} <label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
+18
View File
@@ -0,0 +1,18 @@
.mod-editor fieldset { border: 0; padding: 0; min-width: 0; }
.mod-editor > .section-title { flex-wrap: wrap; }
.mod-editor > .section-title h1 { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; }
.mod-editor > .section-title .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h); }
.mod-editor details { margin: 1rem 0; }
.mod-editor summary { cursor: pointer; overflow-wrap: anywhere; }
.mod-editor select, .mod-editor input, .mod-editor textarea { max-width: 100%; }
.mod-field { display: grid; gap: .4rem; margin: .8rem 0; }
.mod-field textarea { width: 100%; min-height: 4rem; font: inherit; }
.mod-toolbar { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; margin: 1rem 0; }
.mod-toolbar label { display: grid; gap: .4rem; }
.mod-command, .mod-conflict { border: 1px solid var(--border); padding: .8rem; border-radius: .4rem; }
.mod-command > summary { padding-bottom: .5rem; }
.mod-branch { border-left: 2px solid var(--border); padding-left: .8rem; margin: 1rem 0; }
.mod-assets > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .6rem; padding: .6rem 0; }
.mod-conflict-values { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; }
.mod-conflict pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 20rem; overflow: auto; }
@media (max-width: 700px) { .mod-conflict-values { grid-template-columns: 1fr; } }
+76 -5
View File
@@ -5,6 +5,7 @@ import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, ty
import { Renderer, type ResolvedImage } from "./renderer"; import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel";
import type { import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types"; } from "./types";
@@ -138,8 +139,8 @@ export class Engine {
} }
this.persistent = this.makePersistent(); this.persistent = this.makePersistent();
const scope: Scope = { const scope: Scope = {
get: (n) => (n in this.store ? this.store[n] : this.builtins[n]), get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]),
has: (n) => n in this.store || n in this.builtins, has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n),
set: (n, v) => void (this.store[n] = v), set: (n, v) => void (this.store[n] = v),
del: (n) => void delete this.store[n], del: (n) => void delete this.store[n],
}; };
@@ -189,7 +190,7 @@ export class Engine {
} }
} }
const entry = logical ? this.manifest.files[logical] : undefined; const entry = logical ? this.manifest.files[logical] : undefined;
return entry ? `/blobs/${entry.hash}` : null; return entry ? `/api/versions/${this.manifest.versionId}/files/${entry.hash}` : null;
} }
private resolveImage(name: string[]): ResolvedImage | null { private resolveImage(name: string[]): ResolvedImage | null {
@@ -262,6 +263,7 @@ export class Engine {
return this.py.eval(e); return this.py.eval(e);
} catch (err) { } catch (err) {
if (err instanceof JumpSignal) throw err; if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка выражения", err); console.warn("[pgvn] ошибка выражения", err);
return null; return null;
} }
@@ -614,6 +616,48 @@ export class Engine {
private async exec(node: Node) { private async exec(node: Node) {
switch (node.op) { switch (node.op) {
case "nani_exit":
this.stack = [];
this.cur = { label: "", path: [0] };
return;
case "nani_add_choice":
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
target: node.target, handler: node.handler });
this.advance();
return;
case "nani_clear_choice":
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
this.advance();
return;
case "nani_menu": {
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
this.interactionStart();
this.setSkip(false);
const target = await this.wait<string>((done) => {
this.menuEl.textContent = "";
for (const choice of choices) {
const button = el("button", "pg-choice");
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
this.menuEl.append(button);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
this.store.__nani_choices = [];
this.jump(target);
return;
}
case "nani_hide_characters":
this.renderer.commit();
for (const image of this.renderer.snapshot()) {
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
}
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
this.advance();
return;
case "say": case "say":
await this.say(node); await this.say(node);
this.advance(); this.advance();
@@ -693,6 +737,7 @@ export class Engine {
this.py.exec(node.code); this.py.exec(node.code);
} catch (err) { } catch (err) {
if (err instanceof JumpSignal) throw err; if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка python", err, node.code); console.warn("[pgvn] ошибка python", err, node.code);
} }
this.advance(); this.advance();
@@ -771,6 +816,8 @@ export class Engine {
} }
private interp(s: string): string { private interp(s: string): string {
if (this.game.engine === "naninovel") return naniText(s, this.game.naniExpressions ?? {},
(name) => Object.hasOwn(this.store, name) ? this.store[name] : undefined);
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v)); return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
} }
@@ -1066,10 +1113,22 @@ export class Engine {
} }
private snapshotState(): SaveData { private snapshotState(): SaveData {
const anchor = (f: Frame): Frame => {
const label = this.labels.get(f.label)?.label;
let body = label?.body;
for (let i = 0; body && i < f.path.length - 1; i += 2) {
const n = body[f.path[i]];
if (!n) { body = undefined; break; }
body = n.op === "if" ? n.branches[f.path[i + 1]]?.body
: n.op === "menu" ? n.items[f.path[i + 1]]?.body : n.body;
}
return { ...f, path: [...f.path], nodeId: body?.[f.path[f.path.length - 1]]?.id };
};
return { return {
v: 1, v: 1,
cur: { label: this.cur.label, path: [...this.cur.path] }, revision: this.manifest.revision,
stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })), cur: anchor(this.cur),
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
store: encodeValue(this.store), store: encodeValue(this.store),
scene: this.renderer.snapshot(), scene: this.renderer.snapshot(),
music: this.audio.current("music"), music: this.audio.current("music"),
@@ -1101,6 +1160,18 @@ export class Engine {
this.notify("Сохранение не подходит к этой версии игры"); this.notify("Сохранение не подходит к этой версии игры");
return; return;
} }
if (data.revision && data.revision !== this.manifest.revision) {
// IDs repair positions, but cannot prove compatibility of variables/game logic.
const frames = [data.cur, ...data.stack];
const mapped = frames.map((f) => f.nodeId ? this.findNode(f.nodeId) : null);
if (mapped.some((f) => !f)) {
this.notify("После изменения игры позиция сохранения не найдена. Используйте прежнюю ревизию или начните заново");
return;
}
if (!(await this.confirmAsk("Игра изменилась. Позиции найдены по ID, но состояние переменных может быть несовместимо. Попробовать загрузить?", "Загрузить"))) return;
frames.forEach((f, i) => Object.assign(f, mapped[i]));
}
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
this.restart(() => { this.restart(() => {
this.resetStore(); this.resetStore();
Object.assign(this.store, decodeValue(data.store)); Object.assign(this.store, decodeValue(data.store));
+1 -1
View File
@@ -74,7 +74,7 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
const blob = (path: string) => { const blob = (path: string) => {
const f = manifest.files[path]; const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`); if (!f) throw new Error(`в версии нет файла ${path}`);
return fetch(`/blobs/${f.hash}`).then((r) => { return fetch(`/api/versions/${manifest.versionId}/files/${f.hash}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${path}`); if (!r.ok) throw new Error(`${r.status} ${path}`);
return r.json() as Promise<Record<string, Entry>>; return r.json() as Promise<Record<string, Entry>>;
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ async function getJSON<T>(url: string): Promise<T> {
function blob(manifest: Manifest, path: string) { function blob(manifest: Manifest, path: string) {
const f = manifest.files[path]; const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`); if (!f) throw new Error(`в версии нет файла ${path}`);
return `/blobs/${f.hash}`; return `/api/versions/${manifest.versionId}/files/${f.hash}`;
} }
function saveApi(versionId: number): SaveApi { function saveApi(versionId: number): SaveApi {
+88
View File
@@ -0,0 +1,88 @@
// Evaluate the server-parsed Naninovel AST. Game text is never evaluated as JS.
export type NaniAst = any[];
export function naniTruth(value: any): boolean {
if (typeof value === "string") return value !== "" && !["false", "0", "null"].includes(value.toLowerCase());
return Boolean(value);
}
function number(value: any): number {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error(`Naninovel: не число ${String(value)}`);
return n;
}
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): any {
const evaluate = (node: NaniAst): any => {
switch (node[0]) {
case "const": return node[1];
case "name": return lookup(node[1]) ?? 0;
case "unary": {
const value = evaluate(node[2]);
if (node[1] === "!" || node[1] === "not") return !naniTruth(value);
if (node[1] === "-") return -number(value);
if (node[1] === "+") return number(value);
if (node[1] === "~") return ~number(value);
throw new Error(`Naninovel: оператор ${node[1]}`);
}
case "if": return evaluate(naniTruth(evaluate(node[1])) ? node[2] : node[3]);
case "bin": {
const a = evaluate(node[2]);
if (["and", "&&"].includes(node[1])) return naniTruth(a) && naniTruth(evaluate(node[3]));
if (["or", "||"].includes(node[1])) return naniTruth(a) || naniTruth(evaluate(node[3]));
const b = evaluate(node[3]);
switch (node[1]) {
case "+": return typeof a === "string" || typeof b === "string" ? String(a) + String(b) : number(a) + number(b);
case "-": return number(a) - number(b);
case "*": return number(a) * number(b);
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
case "=": case "==": return String(a) === String(b);
case "!=": case "<>": return String(a) !== String(b);
case "<": return number(a) < number(b);
case ">": return number(a) > number(b);
case "<=": return number(a) <= number(b);
case ">=": return number(a) >= number(b);
case "&": return number(a) & number(b);
case "|": return number(a) | number(b);
case "^": return number(a) ^ number(b);
case "<<": return number(a) << number(b);
case ">>": return number(a) >> number(b);
default: throw new Error(`Naninovel: оператор ${node[1]}`);
}
}
case "call": {
const args = node[2].map((value: NaniAst) => number(evaluate(value)));
switch (node[1]) {
case "Random": case "random": case "Rand": {
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
return min + Math.floor(random() * (max - min));
}
case "GetRandValue": return Math.floor(random() * args[0]);
case "Min": return Math.min(...args);
case "Max": return Math.max(...args);
case "Abs": return Math.abs(args[0]);
case "Pow": return Math.pow(args[0], args[1]);
case "Round": { const power = 10 ** (args[1] ?? 0); return Math.round(args[0] * power) / power; }
default: throw new Error(`Naninovel: функция ${node[1]}`);
}
}
default: throw new Error(`Naninovel: узел выражения ${ast[0]}`);
}
};
return evaluate(ast);
}
export function naniText(source: string, expressions: Record<string, NaniAst>, lookup: (name: string) => any): string {
// Expressions are substituted after the locale is selected. Brackets in their
// results are escaped before passing to the shared rich-text renderer.
const escaped = source.replace(/\{([^{}]+)\}/g, (_match, expression: string) => {
const ast = expressions[expression];
if (!ast) throw new Error(`Naninovel: неизвестное выражение в тексте ${expression}`);
return String(evaluateNani(ast, lookup)).replaceAll("{", "{{").replaceAll("[", "[[");
});
return escaped.replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}")
.replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}")
.replace(/<\/(color|size)>/gi, "{/$1}")
.replace(/<br\s*\/?>/gi, "\n");
}
+19 -3
View File
@@ -2,6 +2,7 @@
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is. // Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
import type { Expr, PyStmt } from "./types"; import type { Expr, PyStmt } from "./types";
import { evaluateNani } from "./naninovel";
export class PyDict { export class PyDict {
map = new Map<any, any>(); map = new Map<any, any>();
@@ -157,9 +158,16 @@ function compare(op: string, a: any, b: any): boolean {
} }
function index(len: number, i: number) { function index(len: number, i: number) {
if (!Number.isInteger(i)) throw new Error("Индекс последовательности должен быть целым числом");
return i < 0 ? len + i : i; return i < 0 ? len + i : i;
} }
function safeProperty(name: any): boolean {
if (typeof name !== "string") return typeof name === "number" && Number.isFinite(name);
return !name.startsWith("__") &&
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
}
function methodOf(obj: any, name: string): any { function methodOf(obj: any, name: string): any {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
const m: Record<string, Builtin> = { const m: Record<string, Builtin> = {
@@ -184,7 +192,7 @@ function methodOf(obj: any, name: string): any {
if (i >= 0) obj.splice(i, 1); if (i >= 0) obj.splice(i, 1);
}, },
}; };
return m[name]; return Object.hasOwn(m, name) ? m[name] : undefined;
} }
if (obj instanceof PyDict) { if (obj instanceof PyDict) {
const m: Record<string, Builtin> = { const m: Record<string, Builtin> = {
@@ -205,7 +213,7 @@ function methodOf(obj: any, name: string): any {
clear: () => obj.map.clear(), clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()), copy: () => new PyDict(obj.map.entries()),
}; };
return m[name]; return Object.hasOwn(m, name) ? m[name] : undefined;
} }
if (typeof obj === "string") { if (typeof obj === "string") {
const m: Record<string, Builtin> = { const m: Record<string, Builtin> = {
@@ -230,7 +238,7 @@ function methodOf(obj: any, name: string): any {
isdigit: () => /^\d+$/.test(obj), isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj), isalpha: () => /^\p{L}+$/u.test(obj),
}; };
return m[name]; return Object.hasOwn(m, name) ? m[name] : undefined;
} }
return undefined; return undefined;
} }
@@ -256,6 +264,7 @@ export class Python {
} }
lookup(name: string): any { lookup(name: string): any {
if (!safeProperty(name)) return null;
for (let i = this.locals.length - 1; i >= 0; i--) for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name); if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.scope.has(name)) return this.scope.get(name); if (this.scope.has(name)) return this.scope.get(name);
@@ -266,12 +275,15 @@ export class Python {
eval(e: Expr | null | undefined): any { eval(e: Expr | null | undefined): any {
if (!e) return null; if (!e) return null;
switch (e.k) { switch (e.k) {
case "nani":
return evaluateNani(e.ast, (name) => safeProperty(name) && this.scope.has(name) ? this.scope.get(name) : undefined);
case "const": case "const":
return e.v; return e.v;
case "name": case "name":
return this.lookup(e.id); return this.lookup(e.id);
case "attr": { case "attr": {
const o = this.eval(e.o); const o = this.eval(e.o);
if (!safeProperty(e.a) || typeof o === "function") return null;
if (o === null || o === undefined) { if (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`); this.warnOnce(`атрибут ${e.a} у None`);
return null; return null;
@@ -285,6 +297,7 @@ export class Python {
const o = this.eval(e.o); const o = this.eval(e.o);
const i = this.eval(e.i); const i = this.eval(e.i);
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null; if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
if (!safeProperty(i) || typeof o === "function") return null;
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null; if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null; return o?.[i] ?? null;
} }
@@ -362,6 +375,7 @@ export class Python {
bind(t: Expr, value: any, frame?: Map<string, any>) { bind(t: Expr, value: any, frame?: Map<string, any>) {
switch (t.k) { switch (t.k) {
case "name": case "name":
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
if (frame) frame.set(t.id, value); if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value); else this.scope.set(t.id, value);
return; return;
@@ -372,6 +386,7 @@ export class Python {
} }
case "attr": { case "attr": {
const o = this.eval(t.o); const o = this.eval(t.o);
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
if (o instanceof PyDict) o.set(t.a, value); if (o instanceof PyDict) o.set(t.a, value);
else if (o) o[t.a] = value; else if (o) o[t.a] = value;
return; return;
@@ -380,6 +395,7 @@ export class Python {
const o = this.eval(t.o); const o = this.eval(t.o);
const i = this.eval(t.i); const i = this.eval(t.i);
if (o instanceof PyDict) o.set(i, value); if (o instanceof PyDict) o.set(i, value);
else if (!safeProperty(i) || typeof o === "function") throw new Error("Служебный ключ недоступен");
else if (Array.isArray(o)) o[index(o.length, i)] = value; else if (Array.isArray(o)) o[index(o.length, i)] = value;
else if (o) o[i] = value; else if (o) o[i] = value;
return; return;
+6
View File
@@ -92,6 +92,8 @@ export interface ScriptModule {
} }
export interface GameData { export interface GameData {
engine?: string;
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
format: string; format: string;
title: string | null; title: string | null;
resolution: [number, number]; resolution: [number, number];
@@ -112,11 +114,14 @@ export interface GameData {
} }
export interface Manifest { export interface Manifest {
versionId?: number;
revision?: string;
format: string; format: string;
files: Record<string, { hash: string; size: number }>; files: Record<string, { hash: string; size: number }>;
} }
export interface Frame { export interface Frame {
nodeId?: string;
label: string; label: string;
path: number[]; path: number[];
// values of label parameters shadowed by a call, restored on return // values of label parameters shadowed by a call, restored on return
@@ -132,6 +137,7 @@ export interface ShownImage {
} }
export interface SaveData { export interface SaveData {
revision?: string;
v: 1; v: 1;
cur: Frame; cur: Frame;
stack: Frame[]; stack: Frame[];
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
async function load(name) {
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/player/${name}.ts`, import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
}
const { evaluateNani, naniText } = await load("naninovel");
const c = (value) => ["const", value];
const n = (name) => ["name", name];
const bin = (op, a, b) => ["bin", op, a, b];
test("Naninovel uses its own truth, concatenation and short-circuit semantics", () => {
const evaluate = (ast) => evaluateNani(ast, (key) => ({ points: 3, flag: "false" })[key]);
assert.equal(evaluate(bin("+", c("Score: "), n("points"))), "Score: 3");
assert.equal(evaluate(["if", n("flag"), c("yes"), c("no")]), "no");
assert.equal(evaluate(bin("&&", c(false), ["call", "Missing", []])), false);
assert.equal(evaluate(bin("||", c(true), ["call", "Missing", []])), true);
assert.throws(() => evaluate(["call", "Missing", []]), /функция/);
assert.throws(() => evaluate(bin("/", c(1), c(0))), /ноль/);
});
test("Naninovel translates complete templates before substituting expressions", () => {
const expressions = { points: n("points") };
assert.equal(naniText("<b>Очки: {points}</b><br>Продолжить", expressions, () => 7), "{b}Очки: 7{/b}\nПродолжить");
assert.throws(() => naniText("{unexpected}", expressions, () => 1), /неизвестное выражение/);
assert.equal(naniText("{points}", expressions, () => "{b}"), "{{b}");
});
test("Naninovel AST runs through the shared store and survives JSON saves", async () => {
const { Python } = await load("python");
const store = { score: 2 };
const py = new Python({ get: (key) => Object.hasOwn(store, key) ? store[key] : undefined,
has: (key) => Object.hasOwn(store, key), set: (key, value) => { store[key] = value; }, del: (key) => { delete store[key]; } });
py.exec([{ op: "set", t: { k: "name", id: "score" }, v: { k: "nani", ast: bin("+", n("score"), c(4)) } }]);
assert.equal(JSON.parse(JSON.stringify(store)).score, 6);
});
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/python.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { Python, PyDict } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const c = (v) => ({ k: "const", v });
const attr = (o, a) => ({ k: "attr", o, a });
const sub = (o, i) => ({ k: "sub", o, i: c(i) });
function runtime() {
const store = { fn: Math.max, values: [1, 2], config: {}, data: new PyDict() };
const py = new Python({ get: (n) => store[n], has: (n) => n in store, set: (n, v) => { store[n] = v; }, del: (n) => { delete store[n]; } });
return { py, store };
}
test("PGVN cannot obtain native constructors through attributes or subscripts", () => {
const { py } = runtime();
for (const value of ["text", [], {}, Math.max]) {
for (const key of ["constructor", "__proto__", "prototype", "__defineGetter__"]) {
assert.equal(py.eval(attr(c(value), key)), null);
assert.equal(py.eval(sub(c(value), key)), null);
}
}
assert.equal(py.eval({ k: "name", id: "constructor" }), null);
assert.equal(py.eval(attr({ k: "name", id: "fn" }, "call")), null);
const coercedKey = { toString: () => "constructor" };
assert.equal(py.eval(sub(c({}), coercedKey)), null);
assert.equal(py.eval(attr(c({}), coercedKey)), null);
});
test("PGVN cannot pollute prototypes via assignment", () => {
const { py, store } = runtime();
for (const target of [attr({ k: "name", id: "config" }, "__proto__"), sub({ k: "name", id: "config" }, "constructor"), { k: "name", id: "__proto__" }]) {
assert.throws(() => py.bind(target, { injected: true }), /недоступ/);
}
assert.equal(Object.getPrototypeOf(store.config), Object.prototype);
assert.equal({}.injected, undefined);
});
test("ordinary PGVN list methods, dictionary keys and builtin calls still work", () => {
const { py, store } = runtime();
const name = (id) => ({ k: "name", id });
const call = (f, ...a) => ({ k: "call", f, a: a.map(c), kw: {} });
py.eval(call(attr(name("values"), "append"), 3));
assert.deepEqual(store.values, [1, 2, 3]);
assert.equal(py.eval(sub(name("values"), -1)), 3);
assert.equal(py.eval(call(name("fn"), 4, 9)), 9);
py.bind(sub(name("data"), "constructor"), "ordinary dictionary value");
assert.equal(py.eval(sub(name("data"), "constructor")), "ordinary dictionary value");
assert.throws(() => py.eval(sub(name("values"), "map")), /целым/);
});
-1
View File
@@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/pages/changecredentials.tsx","./src/pages/gamepage.tsx","./src/pages/library.tsx","./src/pages/login.tsx","./src/pages/newgame.tsx","./src/pages/play.tsx","./src/pages/roles.tsx","./src/pages/users.tsx","./src/player/audio.ts","./src/player/engine.ts","./src/player/index.ts","./src/player/python.ts","./src/player/renderer.ts","./src/player/text.ts","./src/player/types.ts"],"version":"5.6.3"}