Initial MVP: local visual novel library with Ren'Py → PGVN converter

- FastAPI backend: admin bootstrap with forced credential change, admin-created
  users with one-time generated passwords, roles as permission sets
- Games and versions with content-addressed blob storage; forks copy only the
  manifest, so mods share assets with the original
- Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python
  subset translated via ast, media converted to WebP/Opus/WebM
- React panel (library search, game page, upload, users, roles) and a
  framework-free web player with saves, history, skip/auto, transitions
- docker compose: postgres, backend, worker, nginx on a single HTTP port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 02:41:32 +03:00
commit 364b312064
72 files changed
+9707

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__
.pytest_cache
*.pyc
.env
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg 7zip git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# unrpyc v2 decompiles .rpyc from Ren'Py 6.18 – 8 on Python 3
ARG UNRPYC_REF=master
RUN git clone --depth 1 --branch ${UNRPYC_REF} https://github.com/CensoredUsername/unrpyc /opt/unrpyc \
&& rm -rf /opt/unrpyc/.git
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
+24
View File
@@ -0,0 +1,24 @@
[alembic]
script_location = migrations
prepend_sys_path = .
[loggers]
keys = root
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[handler_console]
class = logging.StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
View File
Whitespace-only changes.
+69
View File
@@ -0,0 +1,69 @@
"""Content-addressed blob store.
A blob id is ``<sha256><ext>`` (e.g. ``3fa1….webp``); the file lives at
``blobs/3f/a1/<id>``. The extension is part of the id so nginx can serve
blobs with the right Content-Type straight from disk.
"""
import hashlib
import os
import re
import shutil
import tempfile
from pathlib import Path
from app.config import get_settings
BLOB_ID_RE = re.compile(r"^[0-9a-f]{64}(\.[a-z0-9]{1,8})?$")
_CHUNK = 1 << 20
def blob_path(blob_id: str) -> Path:
if not BLOB_ID_RE.match(blob_id):
raise ValueError(f"bad blob id {blob_id!r}")
return get_settings().blobs_dir / blob_id[:2] / blob_id[2:4] / blob_id
def _finalize(tmp: Path, digest: str, ext: str) -> tuple[str, int]:
blob_id = digest + ext.lower()
dest = blob_path(blob_id)
size = tmp.stat().st_size
if dest.exists():
tmp.unlink()
else:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp.chmod(0o644) # mkstemp creates 0600; nginx serves blobs as another user
os.replace(tmp, dest)
return blob_id, size
def _tmp_file() -> Path:
root = get_settings().blobs_dir / "tmp"
root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=root)
os.close(fd)
return Path(name)
def put_file(src: Path, ext: str, move: bool = False) -> tuple[str, int]:
"""Store a file, returning (blob id, size). ``move`` consumes ``src``."""
h = hashlib.sha256()
with src.open("rb") as f:
while chunk := f.read(_CHUNK):
h.update(chunk)
tmp = _tmp_file()
if move:
shutil.move(src, tmp)
else:
shutil.copyfile(src, tmp)
return _finalize(tmp, h.hexdigest(), ext)
def put_bytes(data: bytes, ext: str) -> tuple[str, int]:
tmp = _tmp_file()
tmp.write_bytes(data)
return _finalize(tmp, hashlib.sha256(data).hexdigest(), ext)
def read_bytes(blob_id: str) -> bytes:
return blob_path(blob_id).read_bytes()
+43
View File
@@ -0,0 +1,43 @@
import logging
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Role, User
from app.permissions import SYSTEM_ROLES
from app.security import hash_password
log = logging.getLogger(__name__)
async def bootstrap(db: AsyncSession) -> None:
"""Create system roles and the initial admin on a fresh database."""
roles = {r.name: r for r in (await db.scalars(select(Role))).all()}
for name, perms in SYSTEM_ROLES.items():
role = roles.get(name)
if role is None:
db.add(Role(name=name, permissions=perms, is_system=True))
elif name == "admin":
# admin always has every permission, including newly added ones
role.permissions = perms
await db.flush()
if await db.scalar(select(func.count()).select_from(User)):
await db.commit()
return
settings = get_settings()
admin_role = await db.scalar(select(Role).where(Role.name == "admin"))
db.add(
User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
role_id=admin_role.id,
must_change_password=True,
must_change_username=True,
)
)
await db.commit()
log.warning("Created initial admin '%s'; credentials must be changed on first login",
settings.admin_username)
+36
View File
@@ -0,0 +1,36 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+asyncpg://pgames:pgames@db:5432/pgames"
data_dir: Path = Path("/data")
secret_key: str = "change-me"
session_ttl_hours: int = 24 * 14
cookie_secure: bool = False
admin_username: str = "admin"
admin_password: str = "admin"
worker_poll_seconds: float = 2.0
worker_threads: int = 4
unrpyc_path: Path = Path("/opt/unrpyc/unrpyc.py")
@property
def blobs_dir(self) -> Path:
return self.data_dir / "blobs"
@property
def uploads_dir(self) -> Path:
return self.data_dir / "uploads"
@property
def work_dir(self) -> Path:
return self.data_dir / "work"
@lru_cache
def get_settings() -> Settings:
return Settings()
View File
Whitespace-only changes.
+92
View File
@@ -0,0 +1,92 @@
"""Archive extraction and locating the game directory inside it."""
import os
import shutil
import subprocess
import zipfile
from pathlib import Path
class ArchiveError(Exception):
pass
def _safe_target(root: Path, name: str) -> Path | None:
target = (root / name).resolve()
return target if target.is_relative_to(root.resolve()) else None
def extract(archive: Path, filename: str, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
_extract_zip(archive, dest)
return
seven = shutil.which("7zz") or shutil.which("7z")
if not seven:
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
proc = subprocess.run([seven, "x", "-y", "-snl-", f"-o{dest}", str(archive)],
capture_output=True, text=True)
if proc.returncode not in (0, 1): # 1 = warnings
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
_drop_escaping_links(dest)
# .tar.gz etc. extract to a single inner .tar first
inner = list(dest.iterdir())
if len(inner) == 1 and inner[0].suffix == ".tar":
tar = inner[0]
extract(tar, tar.name, dest / "_tar")
tar.unlink()
def _extract_zip(archive: Path, dest: Path) -> None:
with zipfile.ZipFile(archive) as zf:
for info in zf.infolist():
name = info.filename
if info.flag_bits & 0x800 == 0:
# legacy zips store names in cp437; many were really written as cp866/utf-8
try:
name = name.encode("cp437").decode("utf-8")
except (UnicodeEncodeError, UnicodeDecodeError):
pass
target = _safe_target(dest, name)
if target is None:
continue
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, target.open("wb") as out:
shutil.copyfileobj(src, out, 1 << 20)
def _drop_escaping_links(root: Path) -> None:
for dirpath, dirnames, filenames in os.walk(root):
for name in dirnames + filenames:
p = Path(dirpath, name)
if p.is_symlink():
p.unlink()
def find_game_dir(root: Path) -> Path:
"""Find the Ren'Py `game` directory (the one holding scripts or archives)."""
best: tuple[int, Path] | None = None
for dirpath, _, filenames in os.walk(root):
exts = {Path(f).suffix.lower() for f in filenames}
if not exts & {".rpy", ".rpyc", ".rpa", ".rpymc"}:
continue
p = Path(dirpath)
depth = len(p.relative_to(root).parts)
score = (0 if p.name.lower() == "game" else 100) + depth
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.parts[-3:]]:
continue
if best is None or score < best[0]:
best = (score, p)
if best is None:
raise ArchiveError("В архиве не найдена игра Ren'Py (нет .rpy/.rpyc/.rpa)")
game = best[1]
# scripts in a subfolder of game/ — walk up to game/ itself
for parent in [game, *game.parents]:
if parent.name.lower() == "game":
return parent
if parent == root:
break
return game
+86
View File
@@ -0,0 +1,86 @@
"""Media conversion into web-friendly formats."""
import shutil
import subprocess
import uuid
from pathlib import Path
from PIL import Image, ImageSequence
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"}
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"}
VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"}
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"}
WEBP_MAX = 16383
def kind_of(path: Path) -> str | None:
ext = path.suffix.lower()
if ext in IMAGE_EXTS:
return "image"
if ext in AUDIO_EXTS:
return "audio"
if ext in VIDEO_EXTS:
return "video"
if ext in FONT_EXTS:
return "font"
return None
def target_ext(path: Path) -> str:
kind = kind_of(path)
return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower())
def convert(src: Path, out_dir: Path) -> tuple[Path, str]:
"""Convert one file into out_dir. Returns (output file, extension used for the blob)."""
kind = kind_of(src)
tmp = str(out_dir / uuid.uuid4().hex)
if kind == "image":
return _image(src, Path(tmp))
if kind == "audio":
return _ffmpeg(src, Path(tmp + ".ogg"),
["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg"
if kind == "video":
if src.suffix.lower() == ".webm":
out = Path(tmp + ".webm")
shutil.copyfile(src, out)
return out, ".webm"
return _ffmpeg(src, Path(tmp + ".webm"),
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime",
"-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"]
), ".webm"
out = Path(tmp + src.suffix.lower())
shutil.copyfile(src, out)
return out, src.suffix.lower()
def _image(src: Path, tmp: Path) -> tuple[Path, str]:
with Image.open(src) as img:
animated = getattr(img, "is_animated", False)
if max(img.size) > WEBP_MAX:
out = tmp.with_suffix(".png")
img.save(out, "PNG", optimize=False)
return out, ".png"
out = tmp.with_suffix(".webp")
if animated:
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)]
frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85,
duration=img.info.get("duration", 100), loop=0)
return out, ".webp"
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
img = img.convert("RGBA" if has_alpha else "RGB")
img.save(out, "WEBP", quality=88, method=4, alpha_quality=95)
return out, ".webp"
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
proc = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
*args, str(out)],
capture_output=True, text=True, timeout=3600,
)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}")
return out
+237
View File
@@ -0,0 +1,237 @@
"""Full conversion: uploaded archive → blobs + version manifest."""
import json
import logging
import os
import shutil
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from app.blobs import put_bytes, put_file
from app.config import get_settings
from app.converter import assets
from app.converter.archive import extract, find_game_dir
from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
log = logging.getLogger(__name__)
FORMAT = "pgvn/1"
Progress = Callable[[str], None]
@dataclass
class Result:
manifest: dict[str, Any]
report: dict[str, Any]
title: str | None
cover_source: str | None # blob id of an image suitable as a cover
size: int
def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result:
started = time.monotonic()
if work.exists():
shutil.rmtree(work)
src_root = work / "src"
out_dir = work / "out"
out_dir.mkdir(parents=True)
data = ScriptData()
progress("Распаковка архива")
extract(archive, filename, src_root)
game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa"))
for i, rpa in enumerate(rpas, 1):
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
try:
extract_rpa(rpa, game_dir)
except RpaError as e:
data.warn(str(e))
rpa.unlink()
progress("Декомпиляция .rpyc")
decompiled = decompile_missing(game_dir, data.warn)
progress("Разбор скриптов")
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)),
key=lambda p: p.relative_to(game_dir).as_posix().lower())
for p in rpy_files:
rel = p.relative_to(game_dir).as_posix()
try:
RpyParser(data, rel).parse(p.read_text(encoding="utf-8", errors="replace"))
except ParseError as e:
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
except RecursionError:
data.warn("Слишком глубокая вложенность", rel)
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
files: dict[str, dict[str, Any]] = {}
aliases: dict[str, str] = {}
media = [p for p in game_dir.rglob("*")
if p.is_file() and assets.kind_of(p) and not _is_engine_file(game_dir, p)]
counts = {"image": 0, "audio": 0, "video": 0, "font": 0}
failed: list[str] = []
done = 0
def work_one(p: Path) -> tuple[str, str, str, int]:
out, ext = assets.convert(p, out_dir)
blob_id, size = put_file(out, ext, move=True)
rel = p.relative_to(game_dir).as_posix()
logical = str(PurePosixPath(rel).with_suffix(ext))
return rel, logical, blob_id, size
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
futures = {pool.submit(work_one, p): p for p in media}
for fut in as_completed(futures):
p = futures[fut]
done += 1
if done % 20 == 0 or done == len(media):
progress(f"Конвертация медиа {done}/{len(media)}")
try:
rel, logical, blob_id, size = fut.result()
except Exception as e: # noqa: BLE001 — one broken file must not fail the game
failed.append(p.name)
data.warn("Не удалось сконвертировать файл", f"{p.name}: {e}")
continue
files[logical] = {"hash": blob_id, "size": size}
aliases[rel.lower()] = logical
counts[assets.kind_of(p)] += 1
progress("Сборка формата PGVN")
_auto_images(data, aliases)
missing: set[str] = set()
images = {name: _resolve_image(img, aliases, missing) for name, img in data.images.items()}
audio = {name: _resolve(path, aliases) or path for name, path in data.audio.items()}
scripts: list[str] = []
for rel, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
payload = json.dumps({"file": rel, "labels": labels}, ensure_ascii=False,
separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files[logical] = {"hash": blob_id, "size": size}
scripts.append(logical)
gui = data.gui
resolution = gui.get("resolution") or [
data.config.get("screen_width") or 1280, data.config.get("screen_height") or 720]
title = data.config.get("name") if isinstance(data.config.get("name"), str) else None
game = {
"format": FORMAT,
"engine": "renpy",
"title": title,
"version": data.config.get("version"),
"resolution": resolution,
"entry": "start",
"scripts": scripts,
"characters": data.characters,
"images": images,
"transforms": data.transforms,
"transitions": data.transitions,
"audio": audio,
"files": aliases,
"defaults": data.defaults,
"init": data.init_code,
"ui": _ui(gui, aliases),
}
payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files["game.json"] = {"hash": blob_id, "size": size}
cover = None
for candidate in ("gui/main_menu.png", "gui/main_menu.jpg", "gui/main_menu.webp",
"gui/window_icon.png"):
if (logical := aliases.get(candidate)) and logical in files:
cover = files[logical]["hash"]
break
total = sum(f["size"] for f in files.values())
report = {
"format": FORMAT,
"durationSec": round(time.monotonic() - started, 1),
"scripts": len(rpy_files),
"decompiled": decompiled,
"labels": sum(len(v) for v in data.modules.values()),
"stats": dict(data.stats),
"media": counts,
"failedMedia": failed[:50],
"missingFiles": sorted(missing)[:100],
"warnings": [
{"message": msg, "count": n, "example": data.warning_examples.get(msg)}
for msg, n in data.warnings.most_common()
],
"sourceSize": archive.stat().st_size,
"outputSize": total,
}
return Result({"format": FORMAT, "files": files}, report, title, cover, total)
def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
def _resolve(path: str, aliases: dict[str, str]) -> str | None:
key = path.replace("\\", "/").lower().lstrip("/")
for prefix in ("", "images/", "audio/", "music/", "sound/", "sounds/"):
if (hit := aliases.get(prefix + key)) is not None:
return hit
return None
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
) -> dict[str, Any]:
img = dict(img)
for key in ("file", "movie"):
if isinstance(img.get(key), str):
hit = _resolve(img[key], aliases)
if hit is None:
missing.add(img[key])
else:
img[key] = hit
if "switch" in img:
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
if "composite" in img:
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
for layer in img["composite"]]
return img
def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
"""Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
for rel, logical in aliases.items():
if not rel.startswith("images/") or assets.kind_of(Path(rel)) != "image":
continue
name = os.path.splitext(os.path.basename(rel))[0].lower().strip()
name = " ".join(name.split())
if name and name not in data.images:
data.images[name] = {"file": rel}
def _ui(gui: dict[str, Any], aliases: dict[str, str]) -> dict[str, Any]:
ui: dict[str, Any] = {}
for key, rel in (("textbox", "gui/textbox.png"), ("namebox", "gui/namebox.png"),
("mainMenu", "gui/main_menu.png"), ("nvl", "gui/nvl.png"),
("choice", "gui/button/choice_idle_background.png"),
("choiceHover", "gui/button/choice_hover_background.png")):
if hit := aliases.get(rel):
ui[key] = hit
for key in ("text_font", "name_text_font"):
if isinstance(gui.get(key), str) and (hit := _resolve(gui[key], aliases)):
ui[key] = hit
for key in ("text_size", "name_text_size", "text_color", "accent_color", "textbox_height",
"textbox_yalign", "dialogue_xpos", "dialogue_ypos", "dialogue_width",
"name_xpos", "name_ypos", "interface_text_color", "idle_color", "hover_color",
"choice_button_text_idle_color", "choice_button_text_hover_color"):
if isinstance(gui.get(key), (int, float, str)):
ui[key] = gui[key]
return ui
+185
View File
@@ -0,0 +1,185 @@
"""Translate the subset of Python used by visual novels into PGVN IR.
Expressions become JSON trees ``{"k": kind, ...}``; statements become
``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
Anything outside the subset raises :class:`Unsupported` so the caller can
report it and degrade gracefully.
"""
import ast
from typing import Any
Expr = dict[str, Any]
Stmt = dict[str, Any]
BIN_OPS = {
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
ast.LShift: "<<", ast.RShift: ">>",
}
CMP_OPS = {
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=",
ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not",
}
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
class Unsupported(Exception):
pass
def const(v: Any) -> Expr:
return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr:
try:
tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body)
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def literal(source: str) -> Any:
"""Evaluate a constant expression at conversion time, or raise Unsupported."""
try:
return ast.literal_eval(source.strip())
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
raise Unsupported(f"не константа: {source!r}") from e
def expr(node: ast.AST) -> Expr:
match node:
case ast.Constant(value=v):
if isinstance(v, bytes):
v = v.decode("utf-8", "replace")
if isinstance(v, complex) or v is Ellipsis:
raise Unsupported("неподдерживаемая константа")
return const(v)
case ast.Name(id=name):
return {"k": "name", "id": name}
case ast.Attribute(value=ast.Name(id="store"), attr=attr):
return {"k": "name", "id": attr}
case ast.Attribute(value=value, attr=attr):
return {"k": "attr", "o": expr(value), "a": attr}
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)):
return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
case ast.Subscript(value=value, slice=index):
return {"k": "sub", "o": expr(value), "i": expr(index)}
case ast.BinOp(left=l, op=op, right=r):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
case ast.BoolOp(op=op, values=values):
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
"v": [expr(v) for v in values]}
case ast.UnaryOp(op=op, operand=operand):
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
case ast.Compare(left=l, ops=ops, comparators=rs):
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
"r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [expr(e) for e in elts]}
case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]}
case ast.Dict(keys=keys, values=values):
if any(k is None for k in keys):
raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws):
if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}}
case ast.JoinedStr(values=values):
parts: list[Any] = []
for v in values:
if isinstance(v, ast.Constant):
parts.append(v.value)
elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value))
return {"k": "fmt", "v": parts}
case ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)]):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
raise Unsupported("lambda")
raise Unsupported(f"выражение {type(node).__name__}")
def _opt(node: ast.AST | None) -> Expr | None:
return None if node is None else expr(node)
def target(node: ast.AST) -> Expr:
match node:
case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts):
return {"k": "tuple", "v": [target(e) for e in elts]}
raise Unsupported(f"присваивание в {type(node).__name__}")
def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
out: list[Stmt] = []
for n in nodes:
out.extend(stmt(n))
return out
def stmt(node: ast.stmt) -> list[Stmt]:
match node:
case ast.Assign(targets=targets, value=value):
v = expr(value)
return [{"op": "set", "t": target(t), "v": v} for t in targets]
case ast.AnnAssign(target=t, value=value) if value is not None:
return [{"op": "set", "t": target(t), "v": expr(value)}]
case ast.AugAssign(target=t, op=op, value=value):
if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}")
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
case ast.Expr(value=ast.Constant()):
return [] # docstring / bare literal
case ast.Expr(value=value):
return [{"op": "eval", "e": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.While(test=test, body=body, orelse=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass() | ast.Global() | ast.Nonlocal():
return []
case ast.Break():
return [{"op": "break"}]
case ast.Continue():
return [{"op": "continue"}]
case ast.Import(names=names) | ast.ImportFrom(names=names):
mod = getattr(node, "module", None) or names[0].name
if mod.split(".")[0] in KNOWN_MODULES:
return []
raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")
+106
View File
@@ -0,0 +1,106 @@
"""Reader for Ren'Py .rpa archives (RPA-2.0, RPA-3.x and common variants).
The index is a zlib-compressed pickle, so it is loaded with a restricted
unpickler that only allows plain containers — archives are untrusted input.
"""
import io
import pickle
import zlib
from pathlib import Path
class RpaError(Exception):
pass
class _SafeUnpickler(pickle.Unpickler):
_ALLOWED = {("_codecs", "encode"), ("builtins", "set"), ("builtins", "frozenset"),
("__builtin__", "set"), ("builtins", "bytearray"), ("__builtin__", "bytearray"),
("builtins", "bytes"), ("__builtin__", "bytes")}
def find_class(self, module, name):
if (module, name) in self._ALLOWED:
import builtins
import codecs
return codecs.encode if module == "_codecs" else getattr(builtins, name)
raise pickle.UnpicklingError(f"запрещённый тип в индексе RPA: {module}.{name}")
def _text(v) -> str:
return v.decode("utf-8", "replace") if isinstance(v, bytes) else str(v)
def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
with path.open("rb") as f:
header = f.readline(200).decode("latin-1").strip()
parts = header.split()
if not parts or not parts[0].startswith("RPA-"):
raise RpaError(f"{path.name}: неизвестный формат архива ({header[:20]!r})")
version = parts[0]
try:
offset = int(parts[1], 16)
key = 0
if version != "RPA-2.0":
for token in parts[2:]:
key ^= int(token, 16)
except (IndexError, ValueError) as e:
raise RpaError(f"{path.name}: повреждён заголовок") from e
f.seek(offset)
try:
raw = _SafeUnpickler(io.BytesIO(zlib.decompress(f.read())), encoding="bytes").load()
except (zlib.error, pickle.UnpicklingError, EOFError) as e:
raise RpaError(f"{path.name}: не удалось прочитать индекс: {e}") from e
index: dict[str, list[tuple[int, int, bytes]]] = {}
for name, entries in raw.items():
fixed = []
for entry in entries:
off, length = entry[0] ^ key, entry[1] ^ key
prefix = entry[2] if len(entry) > 2 else b""
if isinstance(prefix, str):
prefix = prefix.encode("latin-1")
fixed.append((off, length, prefix))
index[_text(name)] = fixed
return index
def extract_rpa(path: Path, dest: Path) -> int:
"""Extract all files of an archive into dest. Returns the number of files."""
index = read_index(path)
root = dest.resolve()
count = 0
with path.open("rb") as f:
for name, entries in index.items():
target = (dest / name).resolve()
if not target.is_relative_to(root):
continue
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as out:
for off, length, prefix in entries:
out.write(prefix)
f.seek(off)
remaining = length - len(prefix)
while remaining > 0:
chunk = f.read(min(remaining, 1 << 20))
if not chunk:
break
out.write(chunk)
remaining -= len(chunk)
count += 1
return count
def write_rpa(path: Path, files: dict[str, bytes], key: int = 0x42424242) -> None:
"""Write an RPA-3.0 archive (used by tests)."""
with path.open("wb") as f:
f.write(b"RPA-3.0 " + b"0" * 16 + b" " + b"0" * 8 + b"\n")
index = {}
for name, data in files.items():
off = f.tell()
f.write(data)
index[name] = [(off ^ key, len(data) ^ key, b"")]
idx_off = f.tell()
f.write(zlib.compress(pickle.dumps(index, protocol=2)))
f.seek(0)
f.write(f"RPA-3.0 {idx_off:016x} {key:08x}\n".encode())
+285
View File
@@ -0,0 +1,285 @@
"""Split Ren'Py source into logical lines organised as an indentation tree."""
import re
from dataclasses import dataclass, field
class ParseError(Exception):
def __init__(self, msg: str, filename: str = "", lineno: int = 0):
super().__init__(f"{filename}:{lineno}: {msg}" if filename else msg)
self.msg = msg
self.filename = filename
self.lineno = lineno
@dataclass
class Line:
text: str # logical line, comments removed, continuation lines joined
lineno: int
indent: int
physical: list[str] # original physical lines (for python blocks)
block: list["Line"] = field(default_factory=list)
def block_source(self) -> str:
"""Raw source of the nested block, for python: blocks."""
out: list[str] = []
for child in self.block:
out.extend(child.physical)
out.append(child.block_source())
return "\n".join(s for s in out if s)
_QUOTES = ('"""', "'''", '"', "'", "`")
def _scan(line: str, state: dict) -> tuple[str, bool]:
"""Scan one physical line. Returns text without comment and whether it continues."""
out: list[str] = []
i, n = 0, len(line)
while i < n:
q = state["quote"]
c = line[i]
if q:
if c == "\\" and i + 1 < n:
out.append(line[i:i + 2])
i += 2
continue
if line.startswith(q, i):
state["quote"] = None
out.append(q)
i += len(q)
continue
out.append(c)
i += 1
continue
if c == "#":
break
for quote in _QUOTES:
if line.startswith(quote, i):
state["quote"] = quote
out.append(quote)
i += len(quote)
break
else:
if c in "([{":
state["depth"] += 1
elif c in ")]}":
state["depth"] = max(0, state["depth"] - 1)
out.append(c)
i += 1
text = "".join(out)
cont = state["depth"] > 0 or state["quote"] is not None
if not cont and text.rstrip().endswith("\\") and not text.rstrip().endswith("\\\\"):
text = text.rstrip()[:-1]
cont = True
return text, cont
def logical_lines(source: str, filename: str = "") -> list[Line]:
source = source.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
physical = source.split("\n")
result: list[Line] = []
i = 0
while i < len(physical):
first = physical[i].replace("\t", " ")
state = {"depth": 0, "quote": None}
parts: list[str] = []
raw: list[str] = []
start = i
while True:
raw.append(physical[i])
text, cont = _scan(physical[i].replace("\t", " ") if i != start else first, state)
parts.append(text)
i += 1
if not cont or i >= len(physical):
break
joined = "\n".join(parts).strip()
if not joined:
continue
indent = len(first) - len(first.lstrip(" "))
result.append(Line(joined, start + 1, indent, raw))
return result
def build_tree(lines: list[Line], filename: str = "") -> list[Line]:
root: list[Line] = []
stack: list[tuple[int, list[Line]]] = [(-1, root)]
prev: Line | None = None
for line in lines:
if prev is not None and line.indent > prev.indent and prev.text.endswith(":"):
stack.append((line.indent, prev.block))
else:
while stack[-1][0] > line.indent:
stack.pop()
if stack[-1][0] != line.indent and len(stack) > 1:
# inconsistent dedent; attach to nearest enclosing block
stack.append((line.indent, stack[-1][1]))
stack[-1][1].append(line)
prev = line
return root
def parse_tree(source: str, filename: str = "") -> list[Line]:
return build_tree(logical_lines(source, filename), filename)
# --- in-line tokenizer -------------------------------------------------------
_WORD = re.compile(r"[A-Za-z_\u0080-￿][\w\u0080-￿]*")
_DOTTED = re.compile(r"[A-Za-z_\u0080-￿][\w\u0080-￿]*(\.[A-Za-z_\u0080-￿][\w\u0080-￿]*)*")
_LABEL = re.compile(r"\.?[A-Za-z_\u0080-￿][\w\u0080-￿]*(\.[A-Za-z_\u0080-￿][\w\u0080-￿]*)*")
_IMAGE_PART = re.compile(r"[-\w\u0080-￿]+")
_NUMBER = re.compile(r"[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?")
_STRING_START = re.compile(r"[rRuUbB]{0,2}(\"\"\"|'''|\"|'|`)")
def renpy_string(body: str, raw: bool = False) -> str:
"""Apply Ren'Py string semantics: collapse whitespace, process escapes."""
if raw:
return body
body = re.sub(r"\\\n", "", body)
body = re.sub(r"[ \n]+", " ", body)
def dequote(m: re.Match) -> str:
c = m.group(1)
if c == "n":
return "\n"
if c.startswith("u"):
return chr(int(c[1:], 16))
return c
return re.sub(r"\\(u[0-9a-fA-F]{1,4}|.)", dequote, body)
class Cursor:
def __init__(self, text: str, filename: str = "", lineno: int = 0):
self.text = text
self.pos = 0
self.filename = filename
self.lineno = lineno
def error(self, msg: str) -> ParseError:
return ParseError(f"{msg}: {self.text[:80]!r}", self.filename, self.lineno)
def ws(self) -> None:
while self.pos < len(self.text) and self.text[self.pos] in " \n\t":
self.pos += 1
def eol(self) -> bool:
self.ws()
return self.pos >= len(self.text)
def peek(self, s: str) -> bool:
self.ws()
return self.text.startswith(s, self.pos)
def char(self, s: str) -> bool:
if self.peek(s):
self.pos += len(s)
return True
return False
def _regex(self, rx: re.Pattern) -> str | None:
self.ws()
m = rx.match(self.text, self.pos)
if not m:
return None
self.pos = m.end()
return m.group(0)
def keyword(self, kw: str) -> bool:
self.ws()
save = self.pos
word = self._regex(_WORD)
if word == kw:
return True
self.pos = save
return False
def peek_word(self) -> str | None:
save = self.pos
word = self._regex(_WORD)
self.pos = save
return word
def word(self) -> str | None:
return self._regex(_WORD)
def dotted(self) -> str | None:
return self._regex(_DOTTED)
def label_name(self) -> str | None:
return self._regex(_LABEL)
def image_part(self) -> str | None:
return self._regex(_IMAGE_PART)
def number(self) -> str | None:
return self._regex(_NUMBER)
def string_raw(self) -> str | None:
"""Match a string literal and return its source text (with quotes)."""
self.ws()
m = _STRING_START.match(self.text, self.pos)
if not m:
return None
quote = m.group(1)
i = m.end()
while i < len(self.text):
if self.text[i] == "\\":
i += 2
continue
if self.text.startswith(quote, i):
src = self.text[self.pos:i + len(quote)]
self.pos = i + len(quote)
return src
i += 1
raise self.error("незакрытая строка")
def string(self) -> str | None:
src = self.string_raw()
if src is None:
return None
m = _STRING_START.match(src)
prefix, quote = src[: m.start(1)], m.group(1)
return renpy_string(src[m.end(): -len(quote)], raw="r" in prefix.lower())
def rest(self) -> str:
self.ws()
s = self.text[self.pos:]
self.pos = len(self.text)
return s.strip()
def until(self, stop_words: set[str], stop_chars: str = "") -> str:
"""Python-ish text up to a top-level stop word, stop char or end."""
self.ws()
start = i = self.pos
depth = 0
n = len(self.text)
while i < n:
c = self.text[i]
m = _STRING_START.match(self.text, i) if c in "\"'`rRuUbB" else None
if m and (c in "\"'`" or m.end() > i + 1):
save = self.pos
self.pos = i
self.string_raw()
i = self.pos
self.pos = save
continue
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
elif depth == 0:
if c in stop_chars:
break
if (c.isalpha() or c == "_") and (i == 0 or not (self.text[i - 1].isalnum()
or self.text[i - 1] in "_.")):
w = _WORD.match(self.text, i)
if w and w.group(0) in stop_words and self.text[i - 1:i].isspace():
break
i = w.end() if w else i + 1
continue
i += 1
self.pos = i
return self.text[start:i].strip()
+974
View File
@@ -0,0 +1,974 @@
"""Ren'Py script (.rpy) → PGVN IR."""
import ast
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from app.converter import pyexpr
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
Node = dict[str, Any]
TRANSITIONS: dict[str, dict[str, Any]] = {
"dissolve": {"t": "dissolve", "d": 0.5},
"fade": {"t": "fade", "out": 0.5, "hold": 0.0, "in": 0.5, "color": "#000000"},
"pixellate": {"t": "dissolve", "d": 1.0},
"move": {"t": "move", "d": 0.5},
"ease": {"t": "move", "d": 0.5},
"vpunch": {"t": "punch", "dir": "v", "d": 0.3},
"hpunch": {"t": "punch", "dir": "h", "d": 0.3},
"blinds": {"t": "dissolve", "d": 1.0},
"squares": {"t": "dissolve", "d": 1.0},
"wipeleft": {"t": "wipe", "dir": "left", "d": 1.0},
"wiperight": {"t": "wipe", "dir": "right", "d": 1.0},
"wipeup": {"t": "wipe", "dir": "up", "d": 1.0},
"wipedown": {"t": "wipe", "dir": "down", "d": 1.0},
"zoomin": {"t": "dissolve", "d": 0.5},
"zoomout": {"t": "dissolve", "d": 0.5},
}
for _side in ("left", "right", "top", "bottom"):
for _kind in ("movein", "moveout", "easein", "easeout", "slide", "slideaway",
"push", "irisin", "irisout"):
TRANSITIONS.setdefault(f"{_kind}{_side}", {"t": "slide", "dir": _side, "d": 0.5,
"kind": _kind})
TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pixellate",
"MoveTransition", "CropMove", "PushMove", "ComposeTransition",
"MultipleTransition", "Move"}
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
"offscreenleft", "offscreenright", "default", "reset"}
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
SKIP_BLOCKS = {"screen", "style", "translate", "testcase", "layeredimage", "camera"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@dataclass
class ScriptData:
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
images: dict[str, dict[str, Any]] = field(default_factory=dict)
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
audio: dict[str, str] = field(default_factory=dict)
defaults: list[list[Any]] = field(default_factory=list)
init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order
gui: 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
warnings: Counter = field(default_factory=Counter)
warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter)
def warn(self, kind: str, where: str = "") -> None:
self.warnings[kind] += 1
if where and kind not in self.warning_examples:
self.warning_examples[kind] = where
class RpyParser:
def __init__(self, data: ScriptData, filename: str):
self.d = data
self.filename = filename
# --- helpers -------------------------------------------------------------
def where(self, line: Line) -> str:
return f"{self.filename}:{line.lineno}"
def cursor(self, line: Line, text: str | None = None) -> Cursor:
return Cursor(line.text if text is None else text, self.filename, line.lineno)
def expr(self, source: str, line: Line, default: Expr | None = None) -> Expr | None:
try:
return pyexpr.parse_expr(source)
except Unsupported as e:
self.d.warn(f"Выражение: {e}", self.where(line))
return default
def python(self, source: str, line: Line) -> list[Stmt]:
"""Translate python code statement by statement, skipping unsupported ones."""
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return []
out: list[Stmt] = []
for node in tree.body:
try:
out.extend(pyexpr.stmt(node))
except Unsupported as e:
self.d.warn(f"Python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
return out
# --- top level -------------------------------------------------------------
def parse(self, source: str) -> None:
tree = parse_tree(source, self.filename)
labels: list[Node] = []
current: Node | None = None
for line in tree:
c = self.cursor(line)
first = c.peek_word()
if first == "label":
c.keyword("label")
name = c.label_name()
if not name:
raise c.error("ожидалось имя метки")
if c.char("("):
self.d.warn("Параметры меток не поддерживаются", self.where(line))
current = {"name": name, "body": self.block(line.block)}
labels.append(current)
continue
nodes = self.statement(line, top_level=True)
if nodes and current is not None:
current["body"].extend(nodes)
elif nodes:
# statements before any label run on fall-through into the first label
labels.append(current := {"name": f"_{self.filename}_{line.lineno}",
"body": nodes})
self.d.modules[self.filename] = labels
def block(self, lines: list[Line]) -> list[Node]:
out: list[Node] = []
i = 0
while i < len(lines):
line = lines[i]
word = Cursor(line.text).peek_word()
if word == "if":
branches, i = self.if_chain(lines, i)
out.append({"op": "if", "branches": branches})
continue
if word == "label":
self.d.warn("Вложенные метки не поддерживаются", self.where(line))
out.extend(self.block(line.block))
i += 1
continue
out.extend(self.statement(line))
i += 1
return out
def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]:
branches: list[Node] = []
while i < len(lines):
line = lines[i]
c = self.cursor(line)
kw = c.word()
if kw in ("if", "elif") and (kw == "if") == (not branches):
cond = c.rest().removesuffix(":").strip()
branches.append({"c": self.expr(cond, line, const(False)),
"body": self.block(line.block)})
elif kw == "else" and branches:
branches.append({"c": None, "body": self.block(line.block)})
i += 1
break
else:
break
i += 1
return branches, i
# --- statements ----------------------------------------------------------------
def statement(self, line: Line, top_level: bool = False) -> list[Node]:
text = line.text
if text.startswith("$"):
return self.dollar(text[1:].strip(), line)
c = self.cursor(line)
word = c.peek_word()
handler = getattr(self, f"st_{word}", None) if word else None
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
try:
return handler(line) or []
except ParseError:
raise
if word in SKIP_BLOCKS:
if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line))
return []
say = self.say(line)
if say is not None:
return say
self.d.warn("Неизвестный оператор", f"{self.where(line)}: {text[:60]}")
return []
def _is_say(self, line: Line) -> bool:
c = self.cursor(line)
c.word()
while c.image_part() is not None and not c.peek('"') and not c.peek("'"):
pass
return c.string_raw() is not None or c.peek('"') or c.peek("'")
def dollar(self, code: str, line: Line) -> list[Node]:
stmts = self.python(code, line)
if len(stmts) == 1 and stmts[0]["op"] == "eval":
special = self.renpy_call(stmts[0]["e"], line)
if special is not None:
return special
return [{"op": "exec", "code": stmts}] if stmts else []
def renpy_call(self, e: Expr, line: Line) -> list[Node] | None:
"""Turn common renpy.* calls into native script nodes."""
if e.get("k") != "call":
return None
fname = _dotted_name(e["f"])
args, kw = e["a"], e["kw"]
if fname == "renpy.pause":
return [{"op": "pause", "d": args[0] if args else kw.get("delay")}]
if fname == "renpy.jump" and args:
return [_jump_or_call("jump", args[0])]
if fname == "renpy.call" and args:
return [_jump_or_call("call", args[0])]
if fname in ("renpy.music.play", "renpy.sound.play", "renpy.play"):
ch = "sound" if fname != "renpy.music.play" else "music"
if "channel" in kw and kw["channel"].get("k") == "const":
ch = kw["channel"]["v"]
if not args:
return None
node = {"op": "play", "ch": ch, "files": [args[0]], "loop": _const(kw.get("loop"))}
for key in ("fadein", "fadeout"):
if key in kw:
node[key] = _const(kw[key])
return [node]
if fname in ("renpy.music.stop", "renpy.sound.stop"):
ch = "music" if fname == "renpy.music.stop" else "sound"
if "channel" in kw and kw["channel"].get("k") == "const":
ch = kw["channel"]["v"]
return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}]
if fname == "renpy.show" and args and args[0].get("k") == "const":
return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [],
"with": None}]
if fname == "renpy.hide" and args and args[0].get("k") == "const":
return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}]
if fname == "renpy.scene":
return [{"op": "scene", "img": None, "at": [], "with": None}]
if fname == "renpy.with_statement" and args:
return [{"op": "with", "t": self.transition_expr(args[0])}]
return None
# init / python ----------------------------------------------------------------
def st_init(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("init")
c.number()
if c.keyword("offset"):
return []
if c.keyword("python") or c.keyword("early"):
c.keyword("python")
self.init_python(line.block_source(), line)
return []
for child in line.block:
for node in self.statement(child, top_level=True):
if node["op"] == "exec":
self.d.init_code.extend(node["code"])
else:
self.d.warn("Оператор сцены внутри init", self.where(child))
return []
def st_python(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("python")
if c.keyword("early"):
self.init_python(line.block_source(), line)
return []
code = self.python(line.block_source(), line)
return [{"op": "exec", "code": code}] if code else []
def st_rpy(self, line: Line) -> list[Node]:
return []
def init_python(self, source: str, line: Line) -> None:
try:
tree = ast.parse(_dedent(source))
except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return
for node in tree.body:
if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt = node.targets[0]
name = _assign_name(tgt)
if name and self.define(name, ast.get_source_segment(_dedent(source), node.value)
or ast.unparse(node.value), line):
continue
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func) == "gui.init"
and len(node.value.args) == 2):
try:
self.d.gui["resolution"] = [ast.literal_eval(a) for a in node.value.args]
except ValueError:
pass
continue
try:
self.d.init_code.extend(pyexpr.stmt(node))
except Unsupported as e:
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
# define / default / image / transform -------------------------------------------
def st_define(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("define")
c.number()
name = c.dotted()
if not name:
raise c.error("ожидалось имя")
op = "="
for candidate in ("+=", "|=", "="):
if c.char(candidate):
op = candidate
break
value = c.rest()
if op != "=":
self.d.warn("define с += не поддерживается", self.where(line))
return []
self.define(name, value, line)
return []
def define(self, name: str, value: str, line: Line) -> bool:
"""Handle `define name = value`. Returns False if value was not understood."""
head, _, attr = name.partition(".")
if head in ("config", "gui", "build", "preferences", "style", "_preferences"):
if attr:
try:
(self.d.config if head == "config" else self.d.gui)[attr] = pyexpr.literal(value)
except Unsupported:
pass
return True
if head == "audio" and attr:
try:
self.d.audio[attr] = str(pyexpr.literal(value))
except Unsupported:
pass
return True
if head == "store":
name = attr
e = self.expr(value, line)
if e is None:
return False
if "." in name:
# e.g. persistent.flag = ... : a plain assignment at init time
self.d.init_code.append({"op": "set", "t": pyexpr.parse_expr(name), "v": e})
return True
if e["k"] == "call":
fname = _dotted_name(e["f"])
if fname in ("Character", "DynamicCharacter", "ADVCharacter", "NVLCharacter"):
self.d.characters[name] = self.character(fname, e, line)
return True
if fname in TRANSITION_CLASSES or fname in TRANSITIONS:
self.d.transitions[name] = self.transition_expr(e)
return True
if fname in ("Transform", "Position"):
self.d.transforms[name] = _static_props(e["kw"])
return True
if e["k"] == "name" and e["id"] in TRANSITIONS:
self.d.transitions[name] = TRANSITIONS[e["id"]]
return True
if e["k"] == "name" and e["id"] in self.d.characters:
self.d.characters[name] = dict(self.d.characters[e["id"]])
return True
self.d.init_code.append({"op": "set", "t": {"k": "name", "id": name}, "v": e})
return True
def character(self, fname: str, e: Expr, line: Line) -> dict[str, Any]:
kw = e["kw"]
char: dict[str, Any] = {}
kind = kw.get("kind")
if kind and kind.get("k") == "name":
if kind["id"] == "nvl":
char["nvl"] = True
elif kind["id"] in self.d.characters:
char.update(self.d.characters[kind["id"]])
if fname == "NVLCharacter":
char["nvl"] = True
args = e["a"]
if args:
name = args[0]
if fname == "DynamicCharacter" or (kw.get("dynamic") or {}).get("v"):
char["nameExpr"] = (self.expr(name["v"], line, const(""))
if name.get("k") == "const" and isinstance(name["v"], str)
else name)
elif name.get("k") == "const":
char["name"] = name["v"]
else:
char["nameExpr"] = name
mapping = {"color": "color", "who_color": "color", "what_color": "whatColor",
"what_prefix": "whatPrefix", "what_suffix": "whatSuffix",
"who_prefix": "whoPrefix", "who_suffix": "whoSuffix", "image": "image",
"what_italic": "whatItalic", "what_bold": "whatBold"}
for key, target in mapping.items():
v = kw.get(key)
if v is not None and v.get("k") == "const":
char[target] = v["v"]
return char
def st_default(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("default")
name = c.dotted()
if not name or not c.char("="):
raise c.error("ожидалось default имя = значение")
e = self.expr(c.rest(), line)
if e is not None:
self.d.defaults.append([name.removeprefix("store."), e])
return []
def st_image(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("image")
parts: list[str] = []
while (p := c.image_part()) is not None:
parts.append(p)
if not parts:
raise c.error("ожидалось имя изображения")
name = " ".join(parts)
if c.char("="):
img = self.displayable(c.rest(), line)
elif c.char(":"):
img = self.atl_image(line)
else:
raise c.error("ожидалось = или :")
if img is not None:
self.d.images[name] = img
return []
def displayable(self, source: str, line: Line) -> dict[str, Any] | None:
e = self.expr(source, line)
return self.displayable_expr(e, line) if e is not None else None
def displayable_expr(self, e: Expr, line: Line) -> dict[str, Any] | None:
if e["k"] == "const" and isinstance(e["v"], str):
s = e["v"]
if s.startswith("#"):
return {"color": s}
if "." in s and "/" in s or s.rsplit(".", 1)[-1].lower() in _MEDIA_EXTS:
return {"file": s}
return {"ref": s}
if e["k"] != "call":
self.d.warn("Изображение задано выражением", self.where(line))
return {"expr": e}
fname = _dotted_name(e["f"])
args, kw = e["a"], e["kw"]
if fname == "Solid" and args:
return {"color": _const(args[0])}
if fname in ("Movie",):
play = kw.get("play") or (args[0] if args else None)
return {"movie": _const(play)} if play else None
if fname == "ConditionSwitch":
pairs = []
for i in range(0, len(args) - 1, 2):
cond = args[i]
cexpr = (self.expr(cond["v"], line, const(False))
if cond.get("k") == "const" and isinstance(cond["v"], str) else cond)
sub = self.displayable_expr(args[i + 1], line)
if sub:
pairs.append([cexpr, sub])
return {"switch": pairs}
if fname in ("Composite", "LiveComposite", "im.Composite") and args:
size = _const(args[0])
layers = []
for i in range(1, len(args) - 1, 2):
pos = _const(args[i])
sub = self.displayable_expr(args[i + 1], line)
if sub and isinstance(pos, (list, tuple)):
layers.append({"pos": list(pos), "img": sub})
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
else None}
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
for a in args:
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
if sub:
props = _static_props(kw)
if fname in ("im.Flip", "Flip") and (_const(kw.get("horizontal")) is True):
props["xzoom"] = -1
if fname in ("im.Scale", "im.FactorScale") and len(args) >= 2:
if fname == "im.FactorScale":
props["zoom"] = _const(args[1])
elif len(args) >= 3:
props["size"] = [_const(args[1]), _const(args[2])]
if fname in ("im.Grayscale", "im.Sepia"):
props["filter"] = "grayscale" if fname == "im.Grayscale" else "sepia"
if props:
sub = {**sub, "props": {**sub.get("props", {}), **props}}
return sub
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
return None
def atl_image(self, line: Line) -> dict[str, Any] | None:
frames: list[dict[str, Any]] = []
props: dict[str, Any] = {}
for child in line.block:
c = self.cursor(child)
s = c.string()
if s is not None:
img = self.displayable_expr(const(s), child)
if img:
frames.append(img)
continue
props.update(_atl_props(child.text))
if not frames:
self.d.warn("ATL-изображение без кадров", self.where(line))
return None
if len(frames) > 1:
self.d.warn("Анимированные ATL-изображения упрощены до первого кадра",
self.where(line))
img = dict(frames[0])
if props:
img["props"] = {**img.get("props", {}), **props}
return img
def st_transform(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("transform")
name = c.word()
props: dict[str, Any] = {}
for child in line.block:
props.update(_atl_props(child.text))
if name:
self.d.transforms[name] = props
return []
# visual statements -------------------------------------------------------------
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
if c.keyword("expression"):
e = self.expr(c.until(_SHOW_KEYWORDS), line)
return {"expr": e}
parts: list[str] = []
while True:
save = c.pos
p = c.image_part()
if p is None:
break
if p in _SHOW_KEYWORDS:
c.pos = save
break
parts.append(p)
if not parts:
raise c.error("ожидалось имя изображения")
return {"name": parts}
def show_clauses(self, c: Cursor, line: Line, node: Node) -> None:
node.setdefault("at", [])
node.setdefault("with", None)
while not c.eol():
if c.keyword("as"):
node["as"] = c.image_part()
elif c.keyword("at"):
for item in _split_top(c.until(_SHOW_KEYWORDS - {"at"}, ":")):
node["at"].append(self.transform_ref(item, line))
elif c.keyword("behind"):
node["behind"] = [b.strip() for b in c.until(_SHOW_KEYWORDS, ":").split(",")]
elif c.keyword("onlayer"):
node["layer"] = c.word()
elif c.keyword("zorder"):
node["zorder"] = self.expr(c.until(_SHOW_KEYWORDS, ":"), line)
elif c.keyword("with"):
node["with"] = self.transition(c.until(set(), ":"), line)
elif c.char(":"):
props: dict[str, Any] = {}
for child in line.block:
props.update(_atl_props(child.text))
if props:
node["at"].append({"props": props})
else:
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
break
def transform_ref(self, source: str, line: Line) -> dict[str, Any]:
source = source.strip()
if source in POSITIONS:
return {"pos": source}
if source in self.d.transforms:
return {"props": self.d.transforms[source], "name": source}
e = self.expr(source, line)
if e and e["k"] == "call":
fname = _dotted_name(e["f"])
if fname in ("Position", "Transform"):
return {"props": _static_props(e["kw"])}
if fname in self.d.transforms:
return {"props": self.d.transforms[fname], "name": fname}
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
# transform defined later in another file; the player resolves it by name
return {"name": e["id"]}
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
return {"name": source}
def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("scene")
node: Node = {"op": "scene", "img": None}
if c.keyword("onlayer"):
node["layer"] = c.word()
if not c.eol() and not c.peek("with") and not c.peek("at"):
node["img"] = self.image_spec(c, line)
self.show_clauses(c, line, node)
self.d.stats["scene"] += 1
return [node]
def st_show(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("show")
if c.keyword("screen"):
self.d.warn("Не поддерживается: screen", self.where(line))
return []
if c.keyword("text"):
text = c.string() or ""
node: Node = {"op": "show", "img": {"text": text}}
self.show_clauses(c, line, node)
return [node]
node = {"op": "show", "img": self.image_spec(c, line)}
self.show_clauses(c, line, node)
self.d.stats["show"] += 1
return [node]
def st_hide(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("hide")
if c.keyword("screen"):
return []
spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
self.show_clauses(c, line, node)
node.pop("at", None)
return [node]
def st_with(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("with")
return [{"op": "with", "t": self.transition(c.rest(), line)}]
def transition(self, source: str, line: Line) -> dict[str, Any] | None:
source = source.strip()
if source in ("None", ""):
return None
if source in self.d.transitions:
return self.d.transitions[source]
if source in TRANSITIONS:
return TRANSITIONS[source]
e = self.expr(source, line)
return self.transition_expr(e) if e else TRANSITIONS["dissolve"]
def transition_expr(self, e: Expr) -> dict[str, Any] | None:
if e["k"] == "const" and e["v"] is None:
return None
if e["k"] == "name":
return self.d.transitions.get(e["id"]) or TRANSITIONS.get(e["id"]) or {
"t": "ref", "name": e["id"]}
if e["k"] == "call":
fname = _dotted_name(e["f"])
args = [_const(a) for a in e["a"]]
kw = {k: _const(v) for k, v in e["kw"].items()}
nums = [a for a in args if isinstance(a, (int, float))]
if fname == "Fade":
out = nums[0] if nums else 0.5
hold = nums[1] if len(nums) > 2 else 0.0
fin = nums[2] if len(nums) > 2 else (nums[1] if len(nums) > 1 else out)
return {"t": "fade", "out": out, "hold": hold, "in": fin,
"color": kw.get("color") or "#000000"}
if fname in ("MoveTransition", "Move"):
return {"t": "move", "d": nums[0] if nums else 0.5}
if fname in TRANSITIONS:
return TRANSITIONS[fname]
return {"t": "dissolve", "d": nums[0] if nums else kw.get("time", 0.5) or 0.5}
return TRANSITIONS["dissolve"]
# audio ------------------------------------------------------------------------
def audio_files(self, c: Cursor, line: Line) -> list[Expr]:
src = c.until({"fadein", "fadeout", "loop", "noloop", "if_changed", "volume", "channel"})
e = self.expr(src, line)
if e is None:
return []
if e["k"] in ("list", "tuple"):
return e["v"]
return [e]
def st_play(self, line: Line, op: str = "play") -> list[Node]:
c = self.cursor(line)
c.keyword(op)
ch = c.word() or "music"
if ch not in CHANNELS:
self.d.warn(f"Нестандартный аудиоканал {ch}", self.where(line))
node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)}
while not c.eol():
if c.keyword("fadein"):
node["fadein"] = _num(c.number())
elif c.keyword("fadeout"):
node["fadeout"] = _num(c.number())
elif c.keyword("loop"):
node["loop"] = True
elif c.keyword("noloop"):
node["loop"] = False
elif c.keyword("if_changed"):
node["ifChanged"] = True
elif c.keyword("volume"):
node["volume"] = _num(c.number())
else:
c.rest()
self.d.stats["audio"] += 1
return [node]
def st_queue(self, line: Line) -> list[Node]:
return self.st_play(line, "queue")
def st_stop(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("stop")
node: Node = {"op": "stop", "ch": c.word() or "music"}
if c.keyword("fadeout"):
node["fadeout"] = _num(c.number())
return [node]
def st_voice(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("voice")
if c.keyword("sustain"):
return [{"op": "voice", "sustain": True}]
e = self.expr(c.rest(), line)
return [{"op": "voice", "file": e}] if e else []
# flow ---------------------------------------------------------------------------
def st_jump(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("jump")
if c.keyword("expression"):
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}]
return [{"op": "jump", "target": c.label_name()}]
def st_call(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("call")
if c.keyword("screen"):
self.d.warn("Не поддерживается: call screen", self.where(line))
return []
if c.keyword("expression"):
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
const(""))}
else:
node = {"op": "call", "target": c.label_name()}
if c.peek("("):
self.d.warn("Аргументы call не поддерживаются", self.where(line))
return [node]
def st_return(self, line: Line) -> list[Node]:
return [{"op": "return"}]
def st_pass(self, line: Line) -> list[Node]:
return []
def st_pause(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("pause")
rest = c.rest()
return [{"op": "pause", "d": self.expr(rest, line) if rest else None}]
def st_while(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("while")
cond = c.rest().removesuffix(":").strip()
return [{"op": "while", "c": self.expr(cond, line, const(False)),
"body": self.block(line.block)}]
def st_if(self, line: Line) -> list[Node]:
branches, _ = self.if_chain([line], 0)
return [{"op": "if", "branches": branches}]
def st_window(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("window")
mode = c.word()
return [{"op": "window", "mode": mode}] if mode in ("show", "hide") else []
def st_nvl(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("nvl")
action = c.word()
return [{"op": "nvl", "a": action}] if action in ("clear", "show", "hide") else []
def st_menu(self, line: Line) -> list[Node]:
node: Node = {"op": "menu", "items": [], "say": None}
for child in line.block:
c = self.cursor(child)
if c.keyword("set"):
node["set"] = self.expr(c.rest(), child)
continue
text = c.string()
if text is not None and child.text.rstrip().endswith(":"):
cond = None
if c.keyword("if"):
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
elif c.peek("("):
c.until({"if"}, ":")
if c.keyword("if"):
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
node["items"].append({"text": text, "if": cond, "body": self.block(child.block)})
continue
say = self.say(child)
if say:
node["say"] = say[0]
else:
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
self.d.stats["menu"] += 1
return [node]
# say ------------------------------------------------------------------------------
def say(self, line: Line) -> list[Node] | None:
c = self.cursor(line)
who: str | None = None
who_str: str | None = None
attrs: list[str] = []
first = c.string()
if first is not None:
second = c.string() if not c.eol() else None
if second is not None:
who_str, what = first, second
else:
what = first
else:
who = c.dotted()
if who is None:
return None
while True:
save = c.pos
if c.string_raw() is not None:
c.pos = save
break
p = c.image_part() or (("@" if c.char("@") else None))
if p is None:
c.pos = save
break
if p != "@":
attrs.append(p)
what = c.string()
if what is None:
return None
node: Node = {"op": "say", "what": what}
if who and who != "narrator":
node["who"] = who
if who_str is not None:
node["whoStr"] = who_str
if attrs:
node["attrs"] = attrs
while not c.eol():
if c.keyword("with"):
node["with"] = self.transition(c.rest(), line)
elif c.keyword("nointeract"):
node["interact"] = False
elif c.keyword("id"):
c.word()
elif c.char("("):
c.pos -= 1
c.until({"with", "nointeract", "id"})
else:
c.rest()
self.d.stats["say"] += 1
return [node]
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
def _dedent(source: str) -> str:
import textwrap
return textwrap.dedent(source)
def _dotted_name(e: Expr) -> str:
if e["k"] == "name":
return e["id"]
if e["k"] == "attr":
return f"{_dotted_name(e['o'])}.{e['a']}"
return ""
def _assign_name(t: ast.AST) -> str | None:
if isinstance(t, ast.Name):
return t.id
if isinstance(t, ast.Attribute):
inner = _assign_name(t.value)
return f"{inner}.{t.attr}" if inner else None
return None
def _const(e: Expr | None) -> Any:
if e is None:
return None
if e.get("k") == "const":
return e["v"]
if e.get("k") in ("tuple", "list"):
return [_const(x) for x in e["v"]]
if e.get("k") == "unary" and e["op"] == "-":
v = _const(e["v"])
return -v if isinstance(v, (int, float)) else None
return None
def _num(s: str | None) -> float | None:
try:
return float(s) if s is not None else None
except ValueError:
return None
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
props = {}
for k, v in kw.items():
if k in POSITION_PROPS:
val = _const(v)
if val is not None:
props[k] = val
return props
def _atl_props(text: str) -> dict[str, Any]:
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
tokens = text.replace(",", " ").split()
props: dict[str, Any] = {}
i = 0
while i < len(tokens) - 1:
key = tokens[i]
if key in POSITION_PROPS:
raw = tokens[i + 1]
try:
props[key] = ast.literal_eval(raw)
except (ValueError, SyntaxError):
if raw.startswith("(") and i + 2 < len(tokens):
try:
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
i += 1
except (ValueError, SyntaxError):
pass
i += 2
else:
i += 1
return props
def _split_top(source: str) -> list[str]:
parts, depth, cur = [], 0, []
for ch in source:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
if "".join(cur).strip():
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def _jump_or_call(op: str, target: Expr) -> Node:
if target.get("k") == "const":
return {"op": op, "target": target["v"]}
return {"op": op, "expr": target}
+32
View File
@@ -0,0 +1,32 @@
"""Decompile .rpyc files without sources via unrpyc (best effort)."""
import subprocess
import sys
from pathlib import Path
from app.config import get_settings
def decompile_missing(game_dir: Path, warn) -> int:
"""Decompile every .rpyc that has no matching .rpy. Returns how many were produced."""
pending = [p for p in game_dir.rglob("*.rpyc") if not p.with_suffix(".rpy").exists()]
if not pending:
return 0
script = get_settings().unrpyc_path
if not script.exists():
warn("unrpyc не установлен — скомпилированные скрипты (.rpyc) пропущены")
return 0
produced = 0
# several files per call keeps process start-up overhead low
for i in range(0, len(pending), 50):
batch = pending[i:i + 50]
proc = subprocess.run([sys.executable, str(script), "--try-harder", *map(str, batch)],
capture_output=True, text=True, timeout=1800)
for p in batch:
if p.with_suffix(".rpy").exists():
produced += 1
else:
warn(f"Не удалось декомпилировать {p.name}")
if proc.returncode != 0 and not any(p.with_suffix(".rpy").exists() for p in batch):
warn(f"unrpyc: {proc.stderr.strip()[-300:]}")
return produced
+18
View File
@@ -0,0 +1,18 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
+53
View File
@@ -0,0 +1,53 @@
from collections.abc import Callable
from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.models import User
from app.security import COOKIE_NAME, decode_token
DB = Annotated[AsyncSession, Depends(get_session)]
async def _load_user(db: AsyncSession, token: str | None) -> User:
if not token or not (decoded := decode_token(token)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Не авторизован")
user_id, token_version = decoded
user = await db.get(User, user_id)
if not user or not user.is_active or user.token_version != token_version:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Сессия недействительна")
return user
async def session_user(
db: DB, token: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None
) -> User:
"""Authenticated user, even if they still have to change credentials."""
return await _load_user(db, token)
async def current_user(user: Annotated[User, Depends(session_user)]) -> User:
"""Authenticated user who has finished the forced credential change."""
if user.must_change_password or user.must_change_username:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Необходимо сменить учётные данные")
return user
SessionUser = Annotated[User, Depends(session_user)]
CurrentUser = Annotated[User, Depends(current_user)]
def require(*perms: str) -> Callable:
async def checker(user: CurrentUser) -> User:
missing = [p for p in perms if p not in user.permissions]
if missing:
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {', '.join(missing)}")
return user
return checker
def has_perm(user: User, perm: str) -> bool:
return perm in user.permissions
+41
View File
@@ -0,0 +1,41 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap
from app.config import get_settings
from app.db import SessionLocal
from app.routers import auth, games, roles, users, versions
logging.basicConfig(level=logging.INFO)
@asynccontextmanager
async def lifespan(_: FastAPI):
if get_settings().secret_key == "change-me":
logging.getLogger(__name__).warning("SECRET_KEY is the default value — set it in .env")
async with SessionLocal() as db:
await bootstrap(db)
yield
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json")
for r in (auth, users, roles, games, versions):
app.include_router(r.router)
@app.get("/api/health")
async def health() -> dict[str, str]:
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"})
+99
View File
@@ -0,0 +1,99 @@
from datetime import datetime
from typing import Any
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_username: Mapped[bool] = mapped_column(Boolean, default=False)
token_version: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
role: Mapped[Role] = relationship(lazy="joined")
@property
def permissions(self) -> list[str]:
return sorted(self.role.permissions) if self.is_active else []
class Game(Base):
__tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_by: Mapped[User | None] = relationship(lazy="joined")
class GameVersion(Base):
__tablename__ = "game_versions"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL"))
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: 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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped[User | None] = relationship(lazy="joined")
class Save(Base):
__tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"))
slot: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[dict[str, Any]] = mapped_column(JSONB)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+20
View File
@@ -0,0 +1,20 @@
"""Catalogue of permissions. Roles in the DB are named sets of these codes."""
PERMISSIONS: dict[str, str] = {
"games.play": "Просмотр каталога и игра",
"games.upload": "Загрузка новых игр",
"games.edit": "Редактирование карточек игр",
"games.delete": "Удаление игр и любых версий",
"versions.fork": "Создание своих версий (форков)",
"versions.manage_any": "Управление чужими версиями",
"users.manage": "Управление пользователями",
"roles.manage": "Управление ролями",
}
ALL = sorted(PERMISSIONS)
SYSTEM_ROLES: dict[str, list[str]] = {
"admin": ALL,
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork"],
"player": ["games.play"],
}
+78
View File
@@ -0,0 +1,78 @@
from fastapi import APIRouter, HTTPException, Response, status
from sqlalchemy import select
from app.config import get_settings
from app.deps import DB, SessionUser
from app.models import User
from app.schemas import ChangeCredentialsIn, LoginIn, MeOut
from app.security import (
COOKIE_NAME,
create_token,
hash_password,
validate_new_password,
validate_username,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _set_cookie(response: Response, user: User) -> None:
settings = get_settings()
response.set_cookie(
COOKIE_NAME,
create_token(user.id, user.token_version),
max_age=settings.session_ttl_hours * 3600,
httponly=True,
samesite="lax",
secure=settings.cookie_secure,
)
@router.post("/login", response_model=MeOut)
async def login(body: LoginIn, response: Response, db: DB) -> User:
user = await db.scalar(select(User).where(User.username == body.username))
if not user or not user.is_active or not verify_password(user.password_hash, body.password):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль")
_set_cookie(response, user)
return user
@router.post("/logout", status_code=204)
async def logout(response: Response) -> None:
response.delete_cookie(COOKIE_NAME)
@router.get("/me", response_model=MeOut)
async def me(user: SessionUser) -> User:
return user
@router.post("/change-credentials", response_model=MeOut)
async def change_credentials(
body: ChangeCredentialsIn, response: Response, user: SessionUser, db: DB
) -> User:
if not verify_password(user.password_hash, body.current_password):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Текущий пароль неверен")
if body.new_password == body.current_password:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Новый пароль совпадает со старым")
if err := validate_new_password(body.new_password):
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
new_username = (body.new_username or "").strip()
if user.must_change_username and (not new_username or new_username == user.username):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо задать новый логин")
if new_username and new_username != user.username:
if err := validate_username(new_username):
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
if await db.scalar(select(User.id).where(User.username == new_username)):
raise HTTPException(status.HTTP_409_CONFLICT, "Логин уже занят")
user.username = new_username
user.password_hash = hash_password(body.new_password)
user.must_change_password = False
user.must_change_username = False
user.token_version += 1
await db.commit()
_set_cookie(response, user)
return user
+138
View File
@@ -0,0 +1,138 @@
import io
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from PIL import Image, UnidentifiedImageError
from sqlalchemy import exists, or_, select, true
from starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes
from app.uploads import remove_upload
from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn
router = APIRouter(prefix="/api/games", tags=["games"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
MAX_COVER_BYTES = 20 * 1024 * 1024
def visible_versions_filter(user: User):
"""Versions a user can see: ready ones, plus their own in any state."""
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
return true()
return or_(GameVersion.status == "ready", GameVersion.owner_id == user.id)
async def get_game(db: DB, game_id: int) -> Game:
game = await db.get(Game, game_id)
if not game:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
return game
def can_edit_game(user: User, game: Game) -> bool:
return has_perm(user, "games.edit") or (
has_perm(user, "games.upload") and game.created_by_id == user.id
)
@router.get("", response_model=list[GameOut])
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[Game]:
stmt = select(Game).order_by(Game.title)
if q.strip():
pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Game.title.ilike(pattern),
Game.description.ilike(pattern),
Game.tags.any(q.strip().lower()),
)
)
if tag:
stmt = stmt.where(Game.tags.any(tag.lower()))
stmt = stmt.where(
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
| (Game.created_by_id == user.id)
)
return list((await db.scalars(stmt.limit(500))).all())
@router.post("", response_model=GameOut, status_code=201)
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
game = Game(
title=body.title.strip(),
description=body.description,
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
created_by_id=user.id,
)
db.add(game)
await db.commit()
return game
@router.get("/{game_id}", response_model=GameDetailOut)
async def game_detail(game_id: int, user: Player, db: DB) -> dict:
game = await get_game(db, game_id)
versions = (
await db.scalars(
select(GameVersion)
.where(GameVersion.game_id == game.id, visible_versions_filter(user))
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at)
)
).all()
return {**GameOut.model_validate(game).model_dump(), "versions": list(versions)}
@router.patch("/{game_id}", response_model=GameOut)
async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game:
game = await get_game(db, game_id)
if not can_edit_game(user, game):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
if body.title is not None:
game.title = body.title.strip()
if body.description is not None:
game.description = body.description
if body.tags is not None:
game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()})
await db.commit()
return game
@router.put("/{game_id}/cover", response_model=GameOut)
async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game:
game = await get_game(db, game_id)
if not can_edit_game(user, game):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
data = await request.body()
if len(data) > MAX_COVER_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
try:
game.cover_hash = await run_in_threadpool(make_cover, data)
except (UnidentifiedImageError, OSError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение")
await db.commit()
return game
@router.delete("/{game_id}", status_code=204)
async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))],
db: DB) -> None:
game = await get_game(db, game_id)
version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all()
await db.delete(game)
await db.commit()
for vid in version_ids:
remove_upload(vid)
def make_cover(data: bytes) -> str:
img = Image.open(io.BytesIO(data))
img.thumbnail((640, 640))
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
out = io.BytesIO()
img.save(out, "WEBP", quality=85)
return put_bytes(out.getvalue(), ".webp")[0]
+70
View File
@@ -0,0 +1,70 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from app.deps import DB, CurrentUser, require
from app.models import Role, User
from app.permissions import PERMISSIONS
from app.schemas import RoleIn, RoleOut
router = APIRouter(prefix="/api/roles", tags=["roles"])
RoleAdmin = Annotated[User, Depends(require("roles.manage"))]
def _check_perms(perms: list[str]) -> list[str]:
unknown = [p for p in perms if p not in PERMISSIONS]
if unknown:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Неизвестные права: {', '.join(unknown)}")
return sorted(set(perms))
@router.get("/permissions")
async def list_permissions(_: CurrentUser) -> dict[str, str]:
return PERMISSIONS
@router.get("", response_model=list[RoleOut])
async def list_roles(user: CurrentUser, db: DB) -> list[Role]:
# role list is needed by the user admin page too
if not {"roles.manage", "users.manage"} & set(user.permissions):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав")
return list((await db.scalars(select(Role).order_by(Role.id))).all())
@router.post("", response_model=RoleOut, status_code=201)
async def create_role(body: RoleIn, _: RoleAdmin, db: DB) -> Role:
if await db.scalar(select(Role.id).where(Role.name == body.name)):
raise HTTPException(status.HTTP_409_CONFLICT, "Роль с таким именем уже есть")
role = Role(name=body.name, permissions=_check_perms(body.permissions))
db.add(role)
await db.commit()
return role
@router.put("/{role_id}", response_model=RoleOut)
async def update_role(role_id: int, body: RoleIn, _: RoleAdmin, db: DB) -> Role:
role = await db.get(Role, role_id)
if not role:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Роль не найдена")
if role.name == "admin":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Роль admin нельзя изменить")
if role.is_system and body.name != role.name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Системную роль нельзя переименовать")
role.name = body.name
role.permissions = _check_perms(body.permissions)
await db.commit()
return role
@router.delete("/{role_id}", status_code=204)
async def delete_role(role_id: int, _: RoleAdmin, db: DB) -> None:
role = await db.get(Role, role_id)
if not role:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Роль не найдена")
if role.is_system:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Системную роль нельзя удалить")
if await db.scalar(select(User.id).where(User.role_id == role_id).limit(1)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Роль назначена пользователям")
await db.delete(role)
await db.commit()
+88
View File
@@ -0,0 +1,88 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from app.deps import DB, require
from app.models import Role, User
from app.schemas import UserCreateIn, UserOut, UserUpdateIn, UserWithPasswordOut
from app.security import generate_password, hash_password, validate_username
router = APIRouter(prefix="/api/users", tags=["users"])
Admin = Annotated[User, Depends(require("users.manage"))]
async def _get_user(db: DB, user_id: int) -> User:
user = await db.get(User, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден")
return user
async def _get_role(db: DB, role_id: int) -> Role:
role = await db.get(Role, role_id)
if not role:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Роль не найдена")
return role
@router.get("", response_model=list[UserOut])
async def list_users(_: Admin, db: DB) -> list[User]:
return list((await db.scalars(select(User).order_by(User.username))).all())
@router.post("", response_model=UserWithPasswordOut, status_code=201)
async def create_user(body: UserCreateIn, _: Admin, db: DB) -> dict:
username = body.username.strip()
if err := validate_username(username):
raise HTTPException(status.HTTP_400_BAD_REQUEST, err)
if await db.scalar(select(User.id).where(User.username == username)):
raise HTTPException(status.HTTP_409_CONFLICT, "Логин уже занят")
role = await _get_role(db, body.role_id)
password = generate_password()
user = User(
username=username,
password_hash=hash_password(password),
role_id=role.id,
must_change_password=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return {"user": user, "password": password}
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(user_id: int, body: UserUpdateIn, admin: Admin, db: DB) -> User:
user = await _get_user(db, user_id)
if user.id == admin.id and (body.is_active is False or body.role_id not in (None, user.role_id)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Нельзя отключить или понизить самого себя")
if body.role_id is not None:
user.role_id = (await _get_role(db, body.role_id)).id
if body.is_active is not None and body.is_active != user.is_active:
user.is_active = body.is_active
user.token_version += 1
await db.commit()
await db.refresh(user)
return user
@router.post("/{user_id}/reset-password", response_model=UserWithPasswordOut)
async def reset_password(user_id: int, _: Admin, db: DB) -> dict:
user = await _get_user(db, user_id)
password = generate_password()
user.password_hash = hash_password(password)
user.must_change_password = True
user.token_version += 1
await db.commit()
await db.refresh(user)
return {"user": user, "password": password}
@router.delete("/{user_id}", status_code=204)
async def delete_user(user_id: int, admin: Admin, db: DB) -> None:
user = await _get_user(db, user_id)
if user.id == admin.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Нельзя удалить самого себя")
await db.delete(user)
await db.commit()
+201
View File
@@ -0,0 +1,201 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User
from app.routers.games import get_game
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool:
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
def can_manage(user: User, v: GameVersion) -> bool:
return (
v.owner_id == user.id
or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete")
)
async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading")
db.add(version)
await db.commit()
path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True)
size = 0
try:
with path.open("wb") as f:
async for chunk in request.stream():
f.write(chunk)
size += len(chunk)
except Exception:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise
if size == 0:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
version.status = "queued"
version.progress = "В очереди на конвертацию"
version.size_bytes = size
await db.commit()
return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id)
@router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
db: DB) -> GameVersion:
parent = await get_version(db, user, version_id)
if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
fork = GameVersion(
game_id=parent.game_id,
parent_id=parent.id,
owner_id=user.id,
name=body.name.strip(),
description=body.description,
status="ready",
manifest=parent.manifest,
report=parent.report,
size_bytes=parent.size_bytes,
)
db.add(fork)
await db.commit()
return fork
@router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip()
v.description = body.description
await db.commit()
return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued"
v.progress = "В очереди на конвертацию"
await db.commit()
return v
@router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status == "converting":
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v)
await db.commit()
remove_upload(version_id)
# --- saves -----------------------------------------------------------------
def _check_slot(slot: str) -> str:
if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
await get_version(db, user, version_id)
return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc())
)).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot)))
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot)
if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut)
async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot)
if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data)
db.add(save)
else:
save.title = body.title[:255]
save.data = body.data
await db.commit()
await db.refresh(save)
return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
await db.commit()
+128
View File
@@ -0,0 +1,128 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ORM(BaseModel):
model_config = ConfigDict(from_attributes=True)
class LoginIn(BaseModel):
username: str
password: str
class ChangeCredentialsIn(BaseModel):
current_password: str
new_password: str
new_username: str | None = None
class RoleOut(ORM):
id: int
name: str
permissions: list[str]
is_system: bool
class RoleIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
permissions: list[str]
class UserOut(ORM):
id: int
username: str
role: RoleOut
is_active: bool
must_change_password: bool
must_change_username: bool
created_at: datetime
class MeOut(UserOut):
permissions: list[str]
class UserCreateIn(BaseModel):
username: str
role_id: int
class UserUpdateIn(BaseModel):
role_id: int | None = None
is_active: bool | None = None
class UserWithPasswordOut(BaseModel):
user: UserOut
password: str
class OwnerOut(ORM):
id: int
username: str
class VersionOut(ORM):
id: int
game_id: int
parent_id: int | None
owner: OwnerOut | None
name: str
description: str
status: str
progress: str
size_bytes: int
created_at: datetime
class VersionDetailOut(VersionOut):
report: dict[str, Any] | None
class GameOut(ORM):
id: int
title: str
description: str
tags: list[str]
engine: str
cover_hash: str | None
created_at: datetime
class GameDetailOut(GameOut):
versions: list[VersionOut]
class GameIn(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
tags: list[str] = []
class GameUpdateIn(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
tags: list[str] | None = None
class VersionIn(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str = ""
class SaveIn(BaseModel):
title: str = ""
data: dict[str, Any]
class SaveOut(ORM):
slot: str
title: str
updated_at: datetime
class SaveDetailOut(SaveOut):
data: dict[str, Any]
+60
View File
@@ -0,0 +1,60 @@
import secrets
import string
from datetime import UTC, datetime, timedelta
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from app.config import get_settings
COOKIE_NAME = "pgames_session"
_hasher = PasswordHasher()
_ALPHABET = string.ascii_letters + string.digits
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
def generate_password(length: int = 14) -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(length))
def create_token(user_id: int, token_version: int) -> str:
settings = get_settings()
payload = {
"sub": str(user_id),
"tv": token_version,
"exp": datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours),
}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def decode_token(token: str) -> tuple[int, int] | None:
try:
payload = jwt.decode(token, get_settings().secret_key, algorithms=["HS256"])
return int(payload["sub"]), int(payload["tv"])
except (jwt.PyJWTError, KeyError, ValueError):
return None
def validate_new_password(password: str) -> str | None:
if len(password) < 8:
return "Пароль должен быть не короче 8 символов"
return None
def validate_username(username: str) -> str | None:
if not 3 <= len(username) <= 64:
return "Логин должен быть от 3 до 64 символов"
if not all(c.isalnum() or c in "._-" for c in username):
return "Логин может содержать буквы, цифры и . _ -"
return None
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from app.config import get_settings
def upload_path(version_id: int) -> Path:
return get_settings().uploads_dir / f"{version_id}.archive"
def remove_upload(version_id: int) -> None:
upload_path(version_id).unlink(missing_ok=True)
+108
View File
@@ -0,0 +1,108 @@
"""Conversion worker: polls the DB for queued versions and converts them."""
import asyncio
import logging
import shutil
from sqlalchemy import select, update
from app.blobs import read_bytes
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.models import Game, GameVersion
from app.routers.games import make_cover
from app.uploads import remove_upload, upload_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("worker")
async def set_progress(version_id: int, text: str) -> None:
async with SessionLocal() as db:
await db.execute(update(GameVersion).where(GameVersion.id == version_id)
.values(progress=text[:255]))
await db.commit()
async def claim() -> GameVersion | None:
async with SessionLocal() as db:
version_id = await db.scalar(
select(GameVersion.id).where(GameVersion.status == "queued").order_by(GameVersion.id)
.limit(1).with_for_update(skip_locked=True)
)
if version_id is None:
return None
v = await db.get(GameVersion, version_id)
v.status = "converting"
v.progress = "Начало конвертации"
await db.commit()
return v
async def process(v: GameVersion) -> None:
settings = get_settings()
loop = asyncio.get_running_loop()
work = settings.work_dir / str(v.id)
def progress(text: str) -> None:
asyncio.run_coroutine_threadsafe(set_progress(v.id, text), loop)
log.info("converting version %s (%s)", v.id, v.upload_name)
try:
result = await asyncio.to_thread(convert_archive, upload_path(v.id), v.upload_name or "",
work, progress)
except Exception as e: # noqa: BLE001
log.exception("version %s failed", v.id)
async with SessionLocal() as db:
version = await db.get(GameVersion, v.id)
if version:
version.status = "failed"
version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)}
await db.commit()
return
finally:
shutil.rmtree(work, ignore_errors=True)
async with SessionLocal() as db:
version = await db.get(GameVersion, v.id)
if version is None: # deleted while converting
return
version.status = "ready"
version.progress = ""
version.manifest = result.manifest
version.report = result.report
version.size_bytes = result.size
game = await db.get(Game, version.game_id)
if game and not game.cover_hash and result.cover_source:
try:
game.cover_hash = await asyncio.to_thread(make_cover,
read_bytes(result.cover_source))
except OSError:
pass
await db.commit()
remove_upload(v.id) # sources are not kept once converted
log.info("version %s ready: %s", v.id, result.report.get("media"))
async def main() -> None:
settings = get_settings()
for d in (settings.blobs_dir, settings.uploads_dir, settings.work_dir):
d.mkdir(parents=True, exist_ok=True)
async with SessionLocal() as db:
# jobs interrupted by a restart go back to the queue
await db.execute(update(GameVersion).where(GameVersion.status == "converting")
.values(status="queued"))
await db.commit()
log.info("worker started")
while True:
v = await claim()
if v is None:
await asyncio.sleep(settings.worker_poll_seconds)
continue
await process(v)
if __name__ == "__main__":
asyncio.run(main())
+26
View File
@@ -0,0 +1,26 @@
import asyncio
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app import models # noqa: F401 (registers tables)
from app.config import get_settings
from app.db import Base
target_metadata = Base.metadata
def run(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def main() -> None:
engine = create_async_engine(get_settings().database_url)
async with engine.connect() as conn:
await conn.run_sync(run)
await engine.dispose()
asyncio.run(main())
+21
View File
@@ -0,0 +1,21 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = None
depends_on = None
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,88 @@
"""initial schema
Revision ID: 0001
Revises:
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.create_table(
"roles",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(64), nullable=False, unique=True),
sa.Column("permissions", postgresql.ARRAY(sa.String), nullable=False),
sa.Column("is_system", sa.Boolean, nullable=False),
)
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String(64), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role_id", sa.Integer, sa.ForeignKey("roles.id"), nullable=False),
sa.Column("is_active", sa.Boolean, nullable=False),
sa.Column("must_change_password", sa.Boolean, nullable=False),
sa.Column("must_change_username", sa.Boolean, nullable=False),
sa.Column("token_version", sa.Integer, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(),
nullable=False),
)
op.create_table(
"games",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=False),
sa.Column("tags", postgresql.ARRAY(sa.String), nullable=False),
sa.Column("engine", sa.String(32), nullable=False),
sa.Column("cover_hash", sa.String(80)),
sa.Column("created_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(),
nullable=False),
)
op.execute("CREATE INDEX ix_games_title_trgm ON games USING gin (title gin_trgm_ops)")
op.create_table(
"game_versions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("game_id", sa.Integer, sa.ForeignKey("games.id", ondelete="CASCADE"),
nullable=False, index=True),
sa.Column("parent_id", sa.Integer,
sa.ForeignKey("game_versions.id", ondelete="SET NULL")),
sa.Column("owner_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=False),
sa.Column("status", sa.String(16), nullable=False, index=True),
sa.Column("progress", sa.String(255), nullable=False),
sa.Column("upload_name", sa.String(255)),
sa.Column("manifest", postgresql.JSONB),
sa.Column("report", postgresql.JSONB),
sa.Column("size_bytes", sa.BigInteger, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(),
nullable=False),
)
op.create_table(
"saves",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False),
sa.Column("version_id", sa.Integer,
sa.ForeignKey("game_versions.id", ondelete="CASCADE"), nullable=False),
sa.Column("slot", sa.String(32), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("data", postgresql.JSONB, nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(),
nullable=False),
sa.UniqueConstraint("user_id", "version_id", "slot"),
)
def downgrade() -> None:
for table in ("saves", "game_versions", "games", "users", "roles"):
op.drop_table(table)
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
sqlalchemy[asyncio]==2.0.40
asyncpg==0.30.0
alembic==1.15.2
pydantic-settings==2.9.1
argon2-cffi==23.1.0
pyjwt==2.10.1
pillow==11.2.1
View File
Whitespace-only changes.
+74
View File
@@ -0,0 +1,74 @@
# Sample game used by converter tests and the end-to-end check.
define e = Character("Эйлин", color="#c8ffc8")
define m = Character("[player_name]", color="#c8c8ff")
define n = Character(None, kind=nvl)
define flash = Fade(0.1, 0.0, 0.5, color="#fff")
define audio.theme = "audio/theme.ogg"
default points = 0
default player_name = "Гость"
default inventory = []
image bg room = "bg room.png"
image eileen happy = "eileen happy.png"
image eileen sad = Transform("eileen happy.png", alpha=0.7)
image black = "#000"
transform slightleft:
xalign 0.3 yalign 1.0
init python:
def helper():
return 1
bonus = 5
persistent.seen_intro = False
label start:
scene bg room with fade
play music theme fadein 1.0
"Добро пожаловать в {b}тестовую{/b} новеллу."
show eileen happy at left with dissolve
e "Привет! Как тебя зовут?"
$ player_name = "Алекс"
m "Меня зовут [player_name]."
menu:
e "Что будем делать?"
"Пойдём гулять" if points >= 0:
$ points += 1
$ inventory.append("map")
jump walk
"Останемся дома":
$ points -= 1
call stay_home
e "Вернулись из call."
jump ending
label walk:
show eileen sad at slightleft
e sad "На улице дождь..."
with flash
$ renpy.pause(0.5)
if points > 0 and "map" in inventory:
e "Но у нас есть карта, [points] очков!"
elif points == 0:
e "Ничего."
else:
e "Плохо."
jump ending
label stay_home:
hide eileen with dissolve
"Тихий вечер дома."
$ i = 0
while i < 2:
$ i += 1
"Тик-так [i]."
return
label ending:
stop music fadeout 1.0
scene black with dissolve
centered "Конец. Очки: [points]"
return
+72
View File
@@ -0,0 +1,72 @@
"""Build a small Ren'Py-like game archive for tests and manual checks.
python -m tests.make_sample out.zip
"""
import io
import math
import struct
import sys
import wave
import zipfile
from pathlib import Path
from PIL import Image, ImageDraw
from app.converter.rpa import write_rpa
FIXTURES = Path(__file__).parent / "fixtures" / "sample"
def _png(size, color, text="", alpha=False) -> bytes:
img = Image.new("RGBA" if alpha else "RGB", size, (0, 0, 0, 0) if alpha else color)
d = ImageDraw.Draw(img)
if alpha:
w, h = size
d.ellipse((w * 0.2, h * 0.05, w * 0.8, h * 0.45), fill=color)
d.rectangle((w * 0.25, h * 0.45, w * 0.75, h), fill=color)
else:
for y in range(0, size[1], 40):
d.line((0, y, size[0], y), fill=tuple(min(255, c + 25) for c in color), width=2)
if text:
d.text((20, 20), text, fill=(255, 255, 255, 255))
out = io.BytesIO()
img.save(out, "PNG")
return out.getvalue()
def _wav(seconds=2.0, freq=440) -> bytes:
out = io.BytesIO()
with wave.open(out, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(22050)
frames = b"".join(struct.pack("<h", int(8000 * math.sin(2 * math.pi * freq * i / 22050)))
for i in range(int(22050 * seconds)))
w.writeframes(frames)
return out.getvalue()
def build(path: Path) -> Path:
script = (FIXTURES / "script.rpy").read_bytes()
options = 'define config.name = "Тестовая новелла"\ninit python:\n gui.init(1280, 720)\n'
rpa_path = path.with_suffix(".rpa.tmp")
write_rpa(rpa_path, {
"script.rpy": script,
"images/bg room.png": _png((1280, 720), (60, 70, 110), "bg room"),
"images/eileen happy.png": _png((400, 650), (220, 160, 170), alpha=True),
})
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("SampleGame-1.0-pc/game/archive.rpa", rpa_path.read_bytes())
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options)
zf.writestr("SampleGame-1.0-pc/game/audio/theme.wav", _wav())
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
_png((1280, 720), (30, 30, 50), "Main menu"))
zf.writestr("SampleGame-1.0-pc/renpy/common/00start.rpy", "label _start:\n return\n")
zf.writestr("SampleGame-1.0-pc/SampleGame.exe", b"MZ")
rpa_path.unlink()
return path
if __name__ == "__main__":
print(build(Path(sys.argv[1] if len(sys.argv) > 1 else "sample.zip")))
+81
View File
@@ -0,0 +1,81 @@
import json
import shutil
import pytest
from app.blobs import read_bytes
from app.config import get_settings
from app.converter import pyexpr
from app.converter.pipeline import convert_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from tests.make_sample import build
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
yield tmp_path / "data"
get_settings.cache_clear()
def parse(src: str) -> ScriptData:
d = ScriptData()
RpyParser(d, "t.rpy").parse(src)
return d
def test_expr_translation():
e = pyexpr.parse_expr("a >= 1 and 'x' in inv or not flag")
assert e["k"] == "bool" and e["op"] == "or"
with pytest.raises(pyexpr.Unsupported):
pyexpr.parse_expr("lambda: 1")
def test_say_variants():
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"]
assert body[0] == {"op": "say", "what": "narration"}
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered"
def test_string_whitespace_and_escapes():
d = parse('label start:\n "multi\n line \\"q\\" {b}x{/b}"\n')
assert d.modules["t.rpy"][0]["body"][0]["what"] == 'multi line "q" {b}x{/b}'
def test_if_chain_and_menu():
d = parse("label start:\n if a:\n jump x\n elif b:\n pass\n else:\n"
" return\n menu:\n \"A\" if c:\n jump y\n \"B\":\n"
" pass\n")
body = d.modules["t.rpy"][0]["body"]
assert [b["c"] is None for b in body[0]["branches"]] == [False, False, True]
assert [i["text"] for i in body[1]["items"]] == ["A", "B"]
def test_unsupported_python_is_reported_not_fatal():
d = parse("init python:\n def f():\n pass\n x = 1\nlabel start:\n $ y = x\n")
assert d.warnings and d.init_code[0]["op"] == "set"
def test_full_pipeline(data_dir, tmp_path):
archive = build(tmp_path / "sample.zip")
steps = []
result = convert_archive(archive, "sample.zip", tmp_path / "work", steps.append)
files = result.manifest["files"]
assert "game.json" in files and "script/script.json" in files
assert "images/bg room.webp" in files
game = json.loads(read_bytes(files["game.json"]["hash"]))
assert game["title"] == "Тестовая новелла"
assert game["resolution"] == [1280, 720]
assert game["images"]["bg room"]["file"] == "images/bg room.webp"
assert game["images"]["eileen sad"]["props"]["alpha"] == 0.7
assert "e" in game["characters"]
assert result.cover_source is not None
script = json.loads(read_bytes(files["script/script.json"]["hash"]))
assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"]
if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files