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

+23
View File
@@ -0,0 +1,23 @@
# Copy to .env and change before the first start.
# Long random string used to sign sessions: openssl rand -hex 32
SECRET_KEY=change-me
# Initial admin, created on first start only. Login and password must be
# changed on first sign-in anyway.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
POSTGRES_PASSWORD=pgames
# Port on the VM that Caddy proxies to
HTTP_PORT=8080
# Where converted games are stored on the VM
DATA_PATH=./data
# Parallel media conversions in the worker
WORKER_THREADS=4
# Set to true only if the browser reaches the app over HTTPS (Caddy in front)
COOKIE_SECURE=true
+8
View File
@@ -0,0 +1,8 @@
.env
data/
node_modules/
dist/
__pycache__/
.pytest_cache/
*.pyc
sample.zip
+72
View File
@@ -0,0 +1,72 @@
# PGames — локальная библиотека визуальных новелл
Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит
скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM)
и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры
может быть несколько версий (форков) от разных пользователей; версии делят
общие ассеты, поэтому форк почти не занимает места.
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Установка на VM
```bash
git clone <repo> pgames && cd pgames
cp .env.example .env
# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли
docker compose up -d --build
```
Приложение слушает `http://<IP VM>:8080` (порт — `HTTP_PORT` в `.env`).
Caddy на хосте Proxmox:
```
games.example.lan {
reverse_proxy 192.168.1.50:8080
}
```
Если в браузере сайт открывается по HTTPS через Caddy, оставьте
`COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе
браузер не сохранит сессию.
## Первый вход
Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после
входа нужно задать свой логин и пароль.
Регистрации нет: пользователей создаёт админ в разделе «Пользователи».
Сервер генерирует пароль и показывает его один раз — передайте его человеку,
при первом входе он задаст свой. Права настраиваются через роли
(«Роли»): `admin`, `moderator`, `player` плюс свои.
## Данные и бэкап
* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256);
* том `db` — PostgreSQL (пользователи, игры, версии, сохранения).
Для бэкапа достаточно `pg_dump` и копии `data/blobs`.
## Разработка
```bash
cd backend && pip install -r requirements.txt pytest && python -m pytest
cd frontend && npm install && npm run dev # проксирует /api на :8000
python -m tests.make_sample sample.zip # тестовая игра (из backend/)
```
## Что поддерживает конвертер Ren'Py
`label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide`
с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`,
`Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`,
`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:`
(подмножество Python: переменные, выражения, списки/словари, циклы),
`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`,
`Composite`, `ConditionSwitch`, простые ATL), `transform` со статическими
свойствами, `play/queue/stop/voice`, `pause`, `.rpa`, `.rpyc` (через unrpyc),
автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры.
Пока **не** поддерживается: `screen`-язык (кастомные экраны и мини-игры),
анимации ATL, `layeredimage`, функции/классы в `python` — такие места
перечислены в отчёте конвертации у версии.
+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
+58
View File
@@ -0,0 +1,58 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: pgames
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pgames}
POSTGRES_DB: pgames
volumes:
- db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pgames"]
interval: 5s
retries: 20
backend:
build: ./backend
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://pgames:${POSTGRES_PASSWORD:-pgames}@db:5432/pgames
DATA_DIR: /data
volumes:
- ${DATA_PATH:-./data}:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 5s
retries: 30
depends_on:
db:
condition: service_healthy
worker:
build: ./backend
restart: unless-stopped
command: ["python", "-m", "app.worker"]
env_file: .env
environment:
DATABASE_URL: postgresql+asyncpg://pgames:${POSTGRES_PASSWORD:-pgames}@db:5432/pgames
DATA_DIR: /data
volumes:
- ${DATA_PATH:-./data}:/data
depends_on:
backend:
condition: service_healthy # migrations are applied by backend
web:
build: ./frontend
restart: unless-stopped
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
- ${DATA_PATH:-./data}:/data:ro
depends_on:
- backend
volumes:
db:
+133
View File
@@ -0,0 +1,133 @@
# pgames-manager — архитектура
Локальная библиотека визуальных новелл (сначала Ren'Py). Игры не запускаются
оригинальным движком: архив разбирается на сервере, скрипт и ассеты
перекладываются в собственный формат **PGVN**, а играется копия в нашем
веб-плеере.
## Развёртывание
```
Proxmox host ── Caddy (TLS, домен) ──► http://<IP VM>:8080
│
VM: docker compose│
┌─────────────────────────────────────┴───────────┐
│ web (nginx) : SPA, /api → backend, /blobs → том │
│ backend : FastAPI (uvicorn) │
│ worker : конвертер, тот же образ │
│ db : PostgreSQL 16 │
└──────────────────────────────────────────────────┘
volume data/: blobs/ (ассеты), uploads/ (временные архивы)
```
Приложение отдаёт только HTTP, TLS — на Caddy. Пример Caddyfile:
```
games.example.lan {
reverse_proxy 192.168.1.50:8080
}
```
Очередь задач — сама таблица версий в Postgres (`SELECT … FOR UPDATE SKIP
LOCKED`), Redis не нужен.
## Пользователи и права
* При первом старте создаётся `admin` (пароль из `ADMIN_PASSWORD`, по
умолчанию `admin`) с флагом `must_change_credentials` — при первом входе он
обязан сменить логин и пароль.
* Регистрации нет. Админ создаёт пользователя, сервер генерирует случайный
пароль и показывает его **один раз**; админ передаёт его человеку. У нового
пользователя стоит `must_change_password`.
* Роль = именованный набор прав. Права зашиты в коде
(`backend/app/permissions.py`), роли редактируются в UI. Системные роли:
`admin` (все права, нельзя удалить), `moderator`, `player`.
* Сессия — JWT в httpOnly-cookie; в токене `token_version`, при смене пароля
или деактивации он увеличивается и старые сессии умирают.
## Игры и версии
* `game` — карточка в каталоге (название, описание, обложка, теги).
* `game_version` — конкретная играбельная сборка: `owner`, `parent_id`,
`status` (`uploaded → converting → ready | failed`), `manifest`, отчёт
конвертации. Оригинал — версия без родителя, созданная загрузкой архива.
* Форк версии копирует только манифест (килобайты). Хранилище блобов общее,
поэтому мод, меняющий код, хранит только изменённые файлы скрипта.
### Хранилище блобов
`data/blobs/ab/cd/<sha256>` — неизменяемые файлы, отдаются nginx с
`Cache-Control: immutable`. Манифест версии:
```json
{ "files": { "game.json": {"hash": "…", "size": 1234},
"script/script.json": {"hash": "…", "size": 5678},
"images/bg room.webp": {"hash": "…", "size": 91011} } }
```
Удаление версии не удаляет блобы сразу — их чистит сборщик мусора (блобы,
на которые не ссылается ни один манифест).
## Конвейер конвертации (worker)
1. Распаковка архива (zip — stdlib, остальное — `7zz`), защита от zip-slip.
2. Поиск каталога `game/`.
3. Распаковка `.rpa` (RPA-2/3) внутрь `game/`.
4. Для `.rpyc` без `.rpy` — декомпиляция через unrpyc (best effort).
5. Парсинг `.rpy` собственным парсером → PGVN IR. Python-выражения
разбираются модулем `ast` и транслируются в JSON-дерево выражений;
неподдерживаемое попадает в отчёт как предупреждение.
6. Ассеты: изображения → WebP (с альфой), аудио → Opus (ogg), видео → WebM,
шрифты копируются. Всё складывается в блобы.
7. Манифест сохраняется, исходники удаляются. При ошибке архив остаётся для
повторной попытки.
## Формат PGVN v1
`game.json`:
```json
{
"format": "pgvn/1",
"title": "…", "resolution": [1920, 1080],
"entry": "start",
"scripts": ["script/script.json", "script/ch1.json"],
"characters": {"e": {"name": "Eileen", "color": "#c8ffc8"}},
"images": {"bg room": {"file": "images/bg room.webp"},
"black": {"color": "#000000"}},
"files": {"bg/room.jpg": "bg/room.webp"},
"defaults": [["points", {"k": "const", "v": 0}]]
}
```
Скрипт-модуль (`script/*.json`) — упорядоченный список меток, у каждой тело
из узлов-операторов. Структура остаётся деревом (близко к исходнику, удобно
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы
внутри вложенных блоков, так что сохранения стабильны и компактны.
Узлы (`op`): `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
`pause`, `menu`, `if`, `while`, `jump`, `call`, `return`, `exec`, `window`,
`noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`,
`unary`, `if`, `list`, `tuple`, `dict`, `call`, `fmt`.
Поддерживаемые встроенные функции в выражениях — белый список
(`len`, `min`, `max`, `int`, `str`, `abs`, `round`, `renpy.random.*` и т.п.).
## Плеер
Отдельный TS-модуль без привязки к React (`frontend/src/player`):
* интерпретатор IR + вычислитель выражений;
* DOM-рендер слоёв (`<img>` с GPU-композицией), сцена масштабируется под
окно, переходы через CSS;
* текстбокс с эффектом печати, теги `{b} {i} {color} {size} {w} {p} {nw}`,
интерполяция `[var]`;
* меню выборов, история реплик, skip/auto, быстрые и слотовые сохранения
на сервере, громкость каналов, предзагрузка ближайших картинок.
## Дорожная карта
* Веб-редактор форка: правка скриптов и замена ассетов (манифест уже готов).
* Поддержка screens/ATL-трансформаций в IR.
* Другие движки (TyranoBuilder, RPG Maker MV) через тот же IR.
* Сборщик мусора блобов по расписанию.
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PGames</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
server {
listen 80;
server_name _;
# archives with games can be many gigabytes
client_max_body_size 0;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# content-addressed blobs: /blobs/<sha256><ext> -> /data/blobs/ab/cd/<id>
location ~ "^/blobs/(([0-9a-f]{2})([0-9a-f]{2})[0-9a-f]{60}(\.[a-z0-9]{1,8})?)$" {
alias /data/blobs/$2/$3/$1;
add_header Cache-Control "public, max-age=31536000, immutable";
types {
image/webp webp;
image/png png;
audio/ogg ogg;
video/webm webm;
application/json json;
font/ttf ttf;
font/otf otf;
font/woff woff;
font/woff2 woff2;
}
default_type application/octet-stream;
gzip on;
gzip_types application/json;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri /index.html;
add_header Cache-Control "no-cache";
}
}
+1784
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "pgames-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.3",
"vite": "5.4.11"
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
import { useAuth } from "./auth";
import { ChangeCredentials } from "./pages/ChangeCredentials";
import { GamePage } from "./pages/GamePage";
import { Library } from "./pages/Library";
import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { Users } from "./pages/Users";
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
const { can } = useAuth();
return can(perm) ? children : <div className="page"><p className="muted">Нет доступа к этой странице.</p></div>;
}
export function App() {
const { me, loading, logout, can } = useAuth();
if (loading) return <div className="center muted">Загрузка…</div>;
if (!me) return <Login />;
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
return (
<Routes>
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
<Route
path="*"
element={
<div className="layout">
<header className="topbar">
<NavLink to="/" className="brand">PGames</NavLink>
<nav>
<NavLink to="/" end>Библиотека</NavLink>
{can("games.upload") && <NavLink to="/games/new">Загрузить игру</NavLink>}
{can("users.manage") && <NavLink to="/admin/users">Пользователи</NavLink>}
{can("roles.manage") && <NavLink to="/admin/roles">Роли</NavLink>}
</nav>
<div className="user">
<NavLink to="/account">{me.username}</NavLink>
<span className="muted"> · {me.role.name}</span>
<button className="link" onClick={() => void logout()}>Выйти</button>
</div>
</header>
<main>
<Routes>
<Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} />
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
}
/>
</Routes>
);
}
+118
View File
@@ -0,0 +1,118 @@
export interface Role {
id: number;
name: string;
permissions: string[];
is_system: boolean;
}
export interface User {
id: number;
username: string;
role: Role;
is_active: boolean;
must_change_password: boolean;
must_change_username: boolean;
created_at: string;
}
export interface Me extends User {
permissions: string[];
}
export interface Version {
id: number;
game_id: number;
parent_id: number | null;
owner: { id: number; username: string } | null;
name: string;
description: string;
status: "uploading" | "queued" | "converting" | "ready" | "failed";
progress: string;
size_bytes: number;
created_at: string;
report?: Record<string, any> | null;
}
export interface Game {
id: number;
title: string;
description: string;
tags: string[];
engine: string;
cover_hash: string | null;
created_at: string;
}
export interface GameDetail extends Game {
versions: Version[];
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
let msg = r.statusText;
try {
const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg);
}
return r.status === 204 ? (undefined as T) : r.json();
}
export const api = {
get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => {
let data: any = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
/* empty */
}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText));
};
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file);
});
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, type Me } from "./api";
interface AuthState {
me: Me | null;
loading: boolean;
refresh: () => Promise<void>;
setMe: (me: Me | null) => void;
logout: () => Promise<void>;
can: (perm: string) => boolean;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setMe(await api.get<Me>("/api/auth/me"));
} catch {
setMe(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const onUnauthorized = () => setMe(null);
window.addEventListener("pg-unauthorized", onUnauthorized);
return () => window.removeEventListener("pg-unauthorized", onUnauthorized);
}, [refresh]);
const logout = async () => {
await api.post("/api/auth/logout");
setMe(null);
};
const can = (perm: string) => !!me?.permissions.includes(perm);
return (
<AuthContext.Provider value={{ me, loading, refresh, setMe, logout, can }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("AuthProvider missing");
return ctx;
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./auth";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+74
View File
@@ -0,0 +1,74 @@
import { useState, type FormEvent } from "react";
import { api, type Me } from "../api";
import { useAuth } from "../auth";
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
const { me, setMe, logout } = useAuth();
const needUsername = !!me?.must_change_username;
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (password !== repeat) {
setError("Пароли не совпадают");
return;
}
try {
const updated = await api.post<Me>("/api/auth/change-credentials", {
current_password: current,
new_password: password,
new_username: username.trim() || null,
});
setMe(updated);
setDone(true);
setCurrent("");
setPassword("");
setRepeat("");
} catch (err) {
setError((err as Error).message);
}
};
const form = (
<form className="card auth-card" onSubmit={submit}>
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
{forced && (
<p className="muted">
{needUsername
? "Это первый вход администратора. Задайте собственный логин и пароль."
: "Пароль был выдан администратором. Задайте свой пароль."}
</p>
)}
<label>
Логин{needUsername && " (новый)"}
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Текущий пароль
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password" />
</label>
<label>
Новый пароль (от 8 символов)
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" />
</label>
<label>
Повторите пароль
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
autoComplete="new-password" />
</label>
{error && <div className="error">{error}</div>}
{done && !forced && <div className="ok">Сохранено</div>}
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button>
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
</form>
);
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
}
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
ready: "готова",
failed: "ошибка",
};
export function GamePage() {
const { gameId } = useParams();
const navigate = useNavigate();
const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
const [editing, setEditing] = useState(false);
const load = useCallback(async () => {
try {
setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
} catch (e) {
setError((e as Error).message);
}
}, [gameId]);
useEffect(() => {
void load();
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].includes(v.status));
useEffect(() => {
if (!busy) return;
const t = setInterval(() => void load(), 2000);
return () => clearInterval(t);
}, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>;
// the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => {
setError("");
try {
await fn();
await load();
} catch (e) {
setError((e as Error).message);
}
};
const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name);
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}));
};
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
...renderTree(v.id, depth + 1),
]);
return (
<div className="page">
<Link to="/" className="muted">← Библиотека</Link>
<div className="game-head">
<div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div>
<div className="game-info">
{editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : (
<>
<h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && (
<label className="button">
Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.upload") && (
<label className="button">
Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)}
</div>
</>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
<h2>Версии</h2>
<div className="versions">{renderTree(null, 0)}</div>
</div>
);
}
function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) {
const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => {
if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
};
return (
<div className="version" style={{ marginLeft: props.depth * 24 }}>
<div className="version-main">
<div>
<b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
</div>
</div>
<div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && v.status !== "converting" && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div>
</div>
{report !== undefined && <Report report={report} />}
</div>
);
}
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>;
return (
<div className="report">
<div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span>
</div>
{report.missingFiles?.length > 0 && (
<details>
<summary>Не найдены файлы ({report.missingFiles.length})</summary>
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
</details>
)}
{report.warnings?.length > 0 && (
<details>
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
<table>
<tbody>
{report.warnings.map((w: any) => (
<tr key={w.message}>
<td>{w.count}×</td>
<td>{w.message}</td>
<td className="muted small">{w.example}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
);
}
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
const [error, setError] = useState("");
const save = async () => {
try {
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
onDone();
} catch (e) {
setError((e as Error).message);
}
};
return (
<div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>}
<div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { api, type Game } from "../api";
export function Library() {
const [params, setParams] = useSearchParams();
const q = params.get("q") ?? "";
const [query, setQuery] = useState(q);
const [games, setGames] = useState<Game[] | null>(null);
const [error, setError] = useState("");
useEffect(() => {
const t = setTimeout(() => setParams(query ? { q: query } : {}, { replace: true }), 250);
return () => clearTimeout(t);
}, [query, setParams]);
useEffect(() => {
let cancelled = false;
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
.then((g) => !cancelled && setGames(g))
.catch((e) => !cancelled && setError(e.message));
return () => {
cancelled = true;
};
}, [q]);
return (
<div className="page">
<div className="search">
<input placeholder="Поиск по названию, описанию или тегу…" value={query}
onChange={(e) => setQuery(e.target.value)} autoFocus />
</div>
{error && <div className="error">{error}</div>}
{games === null ? (
<p className="muted">Загрузка…</p>
) : games.length === 0 ? (
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p>
) : (
<div className="grid">
{games.map((g) => (
<Link key={g.id} to={`/games/${g.id}`} className="game-card">
<div className="cover">
{g.cover_hash ? <img src={`/blobs/${g.cover_hash}`} alt="" loading="lazy" /> : <span>{g.title[0]}</span>}
</div>
<div className="game-card-body">
<b>{g.title}</b>
<div className="tags">
{g.tags.slice(0, 4).map((t) => <span key={t} className="tag">{t}</span>)}
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, type FormEvent } from "react";
import { api, type Me } from "../api";
import { useAuth } from "../auth";
export function Login() {
const { setMe } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError("");
try {
setMe(await api.post<Me>("/api/auth/login", { username, password }));
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="center">
<form className="card auth-card" onSubmit={submit}>
<h1>PGames</h1>
<p className="muted">Локальная библиотека визуальных новелл</p>
<label>
Логин
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
</label>
<label>
Пароль
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" />
</label>
{error && <div className="error">{error}</div>}
<button className="primary" disabled={busy || !username || !password}>Войти</button>
</form>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { api, uploadRaw, type Game } from "../api";
export function NewGame() {
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState("");
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!file) return;
setError("");
setProgress(0);
try {
const game = await api.post<Game>("/api/games", {
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
await uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}`,
file, setProgress);
navigate(`/games/${game.id}`);
} catch (err) {
setError((err as Error).message);
setProgress(null);
}
};
return (
<div className="page narrow">
<form className="card" onSubmit={submit}>
<h1>Новая игра</h1>
<p className="muted">
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его,
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники.
</p>
<label>
Архив
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<label>
Название
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
</label>
<label>
Описание
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} />
</label>
<label>
Теги через запятую
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
</label>
{error && <div className="error">{error}</div>}
{progress !== null && (
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div>
)}
<button className="primary" disabled={!file || progress !== null}>
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
</button>
</form>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { Engine } from "../player/engine";
export function Play() {
const { versionId } = useParams();
const navigate = useNavigate();
const host = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState("Загрузка…");
const [error, setError] = useState("");
useEffect(() => {
let engine: Engine | null = null;
let cancelled = false;
// the player is a separate chunk: the library UI does not pay for it
import("../player").then(({ launch }) =>
launch(host.current!, Number(versionId), () => navigate(-1), setStatus),
).then((e) => {
if (cancelled) e.destroy();
else {
engine = e;
setStatus("");
}
}).catch((e) => setError((e as Error).message));
return () => {
cancelled = true;
engine?.destroy();
};
}, [versionId, navigate]);
return (
<div className="play">
<div ref={host} className="play-host" />
{(status || error) && (
<div className="play-status">
{error ? <span className="error">{error}</span> : status}
{error && <button onClick={() => navigate(-1)}>Назад</button>}
</div>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from "react";
import { api, type Role } from "../api";
export function Roles() {
const [roles, setRoles] = useState<Role[]>([]);
const [perms, setPerms] = useState<Record<string, string>>({});
const [newName, setNewName] = useState("");
const [error, setError] = useState("");
const load = useCallback(async () => {
const [r, p] = await Promise.all([api.get<Role[]>("/api/roles"),
api.get<Record<string, string>>("/api/roles/permissions")]);
setRoles(r);
setPerms(p);
}, []);
useEffect(() => {
load().catch((e) => setError(e.message));
}, [load]);
const run = async (fn: () => Promise<unknown>) => {
setError("");
try {
await fn();
await load();
} catch (e) {
setError((e as Error).message);
}
};
const toggle = (role: Role, perm: string) => {
const next = role.permissions.includes(perm)
? role.permissions.filter((p) => p !== perm)
: [...role.permissions, perm];
void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next }));
};
return (
<div className="page">
<h1>Роли и права</h1>
<p className="muted">Роль — набор прав. Роль admin всегда имеет все права.</p>
{error && <div className="error">{error}</div>}
<div className="table-wrap">
<table className="table roles">
<thead>
<tr>
<th>Право</th>
{roles.map((r) => (
<th key={r.id}>
{r.name}
{!r.is_system && (
<button className="link danger" title="Удалить роль"
onClick={() => confirm(`Удалить роль ${r.name}?`) && void run(() => api.del(`/api/roles/${r.id}`))}>✕</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(perms).map(([code, label]) => (
<tr key={code}>
<td>{label} <span className="muted small">{code}</span></td>
{roles.map((r) => (
<td key={r.id} className="center-cell">
<input type="checkbox" checked={r.permissions.includes(code)} disabled={r.name === "admin"}
onChange={() => toggle(r, code)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<form className="card inline-form" onSubmit={(e) => {
e.preventDefault();
void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] })).then(() => setNewName(""));
}}>
<input placeholder="Название новой роли" value={newName} onChange={(e) => setNewName(e.target.value)} />
<button className="primary" disabled={!newName.trim()}>Добавить роль</button>
</form>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { api, type Role, type User } from "../api";
import { useAuth } from "../auth";
interface Issued {
user: User;
password: string;
}
export function Users() {
const { me } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [username, setUsername] = useState("");
const [roleId, setRoleId] = useState<number | "">("");
const [issued, setIssued] = useState<Issued | null>(null);
const [error, setError] = useState("");
const load = useCallback(async () => {
const [u, r] = await Promise.all([api.get<User[]>("/api/users"), api.get<Role[]>("/api/roles")]);
setUsers(u);
setRoles(r);
setRoleId((cur) => cur || (r.find((x) => x.name === "player")?.id ?? r[0]?.id ?? ""));
}, []);
useEffect(() => {
load().catch((e) => setError(e.message));
}, [load]);
const run = async (fn: () => Promise<unknown>) => {
setError("");
try {
await fn();
await load();
} catch (e) {
setError((e as Error).message);
}
};
const create = (e: FormEvent) => {
e.preventDefault();
void run(async () => {
setIssued(await api.post<Issued>("/api/users", { username: username.trim(), role_id: roleId }));
setUsername("");
});
};
return (
<div className="page">
<h1>Пользователи</h1>
<form className="card inline-form" onSubmit={create}>
<input placeholder="Логин нового пользователя" value={username} onChange={(e) => setUsername(e.target.value)} />
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<button className="primary" disabled={username.trim().length < 3}>Создать</button>
</form>
{issued && (
<div className="card issued">
<p>
Пароль для <b>{issued.user.username}</b> (показывается один раз, передайте его пользователю —
при первом входе он задаст свой):
</p>
<div className="password-box">
<code>{issued.password}</code>
<button onClick={() => void navigator.clipboard?.writeText(`${issued.user.username} / ${issued.password}`)}>
Копировать
</button>
<button onClick={() => setIssued(null)}>Скрыть</button>
</div>
</div>
)}
{error && <div className="error">{error}</div>}
<table className="table">
<thead>
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
<td>
{u.username}
{u.id === me?.id && <span className="muted"> (вы)</span>}
</td>
<td>
<select value={u.role.id} disabled={u.id === me?.id}
onChange={(e) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: Number(e.target.value) }))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</td>
<td>
{u.is_active ? "активен" : "отключён"}
{u.must_change_password && <span className="muted"> · ждёт смены пароля</span>}
</td>
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
<td className="actions">
{u.id !== me?.id && (
<>
<button onClick={() => void run(async () =>
setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)))}>Сбросить пароль</button>
<button onClick={() => void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }))}>
{u.is_active ? "Отключить" : "Включить"}
</button>
<button className="danger" onClick={() => confirm(`Удалить ${u.username}?`) &&
void run(() => api.del(`/api/users/${u.id}`))}>Удалить</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots.
export interface Volumes {
music: number;
sound: number;
voice: number;
}
interface Channel {
el: HTMLAudioElement | null;
file: string | null;
loop: boolean;
queue: string[];
}
function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) {
if (seconds <= 0) {
el.volume = to;
done?.();
return;
}
const start = performance.now();
const step = () => {
const t = Math.min(1, (performance.now() - start) / (seconds * 1000));
el.volume = Math.max(0, Math.min(1, from + (to - from) * t));
if (t < 1) requestAnimationFrame(step);
else done?.();
};
requestAnimationFrame(step);
}
export class AudioSystem {
channels = new Map<string, Channel>();
constructor(public volumes: Volumes, private url: (file: string) => string | null) {}
private group(ch: string): keyof Volumes {
if (ch === "music") return "music";
if (ch === "voice") return "voice";
return "sound";
}
private channel(name: string): Channel {
let c = this.channels.get(name);
if (!c) {
c = { el: null, file: null, loop: false, queue: [] };
this.channels.set(name, c);
}
return c;
}
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
ifChanged?: boolean }) {
const c = this.channel(ch);
const loop = opts.loop ?? ch === "music";
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
c.queue = files.slice(1);
c.loop = loop;
this.start(ch, c, files[0], opts.fadein ?? 0);
}
queue(ch: string, files: string[], loop?: boolean | null) {
const c = this.channel(ch);
if (!c.el || c.el.paused) {
this.play(ch, files, { loop });
return;
}
c.queue.push(...files);
if (loop != null) c.loop = loop;
}
private start(ch: string, c: Channel, file: string | undefined, fadein: number) {
if (!file) return;
const src = this.url(file);
if (!src) {
console.warn("[pgvn] нет аудиофайла", file);
return;
}
const el = new Audio(src);
const target = this.volumes[this.group(ch)];
el.volume = fadein > 0 ? 0 : target;
el.loop = c.loop && c.queue.length === 0;
el.onended = () => {
if (c.el !== el) return;
const next = c.queue.shift();
if (next) {
if (c.loop) c.queue.push(next);
this.start(ch, c, next, 0);
} else {
c.el = null;
c.file = null;
}
};
c.el = el;
c.file = file;
el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука"));
if (fadein > 0) fade(el, 0, target, fadein);
}
stop(ch: string, fadeout = 0) {
const c = this.channels.get(ch);
if (!c?.el) return;
const el = c.el;
c.el = null;
c.file = null;
c.queue = [];
fade(el, el.volume, 0, fadeout, () => el.pause());
}
stopAll() {
for (const ch of this.channels.keys()) this.stop(ch);
}
setVolumes(v: Volumes) {
this.volumes = v;
for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)];
}
current(ch: string): { file: string; loop: boolean } | null {
const c = this.channels.get(ch);
return c?.file ? { file: c.file, loop: c.loop } : null;
}
}
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
import "./player.css";
import { Engine, type SaveApi } from "./engine";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) throw new Error(`${r.status} ${url}`);
return r.json();
}
function blob(manifest: Manifest, path: string) {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return `/blobs/${f.hash}`;
}
function saveApi(versionId: number): SaveApi {
const base = `/api/versions/${versionId}/saves`;
return {
list: () => getJSON(base),
async get(slot) {
const r = await fetch(`${base}/${slot}`);
if (r.status === 404) return null;
if (!r.ok) throw new Error(`${r.status}`);
return (await r.json()).data as SaveData;
},
async put(slot, title, data) {
const r = await fetch(`${base}/${slot}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, data }),
});
if (!r.ok) throw new Error(`${r.status}`);
},
async remove(slot) {
await fetch(`${base}/${slot}`, { method: "DELETE" });
},
};
}
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
onProgress: (text: string) => void): Promise<Engine> {
onProgress("Загрузка манифеста");
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit);
engine.start();
return engine;
}
+195
View File
@@ -0,0 +1,195 @@
.pg-root {
position: relative;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
user-select: none;
}
.pg-stage {
--pg-accent: #ff9f43;
--pg-text: #ffffff;
position: absolute;
left: 50%;
top: 50%;
overflow: hidden;
background: #000;
color: var(--pg-text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: var(--pg-font);
transform-origin: center;
cursor: default;
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
.pg-fill { position: absolute; inset: 0; }
.pg-image { position: absolute; will-change: transform, opacity; }
.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; }
.pg-hidden { display: none !important; }
.pg-textbox {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92));
border-top: 2px solid rgba(255, 255, 255, 0.12);
}
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
.pg-textbox.pg-ui-hidden { opacity: 0; }
.pg-namebox {
position: absolute;
top: 0;
transform: translateY(-50%);
padding: 0.15em 0.8em;
font-size: var(--pg-name-font);
font-weight: 600;
color: var(--pg-accent);
background: rgba(10, 12, 24, 0.85);
border-radius: 0.3em;
text-shadow: 0 1px 2px #000;
}
.pg-textbox-image .pg-namebox { background: transparent; }
.pg-text {
position: absolute;
line-height: 1.35;
white-space: pre-wrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; }
@keyframes pg-blink { 50% { opacity: 0.1; } }
.pg-quick {
position: absolute;
bottom: 0.4em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.2em;
}
.pg-quick button {
background: none;
border: 0;
color: rgba(255, 255, 255, 0.55);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.2em 0.6em;
cursor: pointer;
}
.pg-quick button:hover, .pg-quick button.pg-on { color: var(--pg-accent); }
.pg-menu {
position: absolute;
inset: 0 0 26% 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.pg-choice {
min-width: 50%;
max-width: 75%;
padding: 0.45em 1.2em;
font: inherit;
color: #fff;
background: rgba(15, 18, 35, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.3em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
.pg-nvl {
position: absolute;
inset: 0;
padding: 5% 15%;
background: rgba(0, 0, 0, 0.72) center / cover;
display: flex;
flex-direction: column;
gap: 0.6em;
overflow: hidden;
}
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
.pg-nvl-what { white-space: pre-wrap; }
.pg-centered {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 15%;
white-space: pre-wrap;
text-shadow: 0 2px 4px #000;
}
.pg-notify {
position: absolute;
top: 2%;
left: 2%;
padding: 0.4em 1em;
font-size: calc(var(--pg-font) * 0.7);
background: rgba(0, 0, 0, 0.8);
border-left: 4px solid var(--pg-accent);
}
.pg-screen {
position: absolute;
inset: 0;
background: rgba(8, 10, 20, 0.94) center / cover;
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
}
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; }
.pg-screen-head h2 { margin: 0 0 0.6em; color: var(--pg-accent); font-weight: 600; }
.pg-screen button {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.3em;
padding: 0.4em 1em;
cursor: pointer;
}
.pg-screen button:hover:not(:disabled) { border-color: var(--pg-accent); color: var(--pg-accent); }
.pg-screen button:disabled { opacity: 0.4; cursor: default; }
.pg-close { background: none !important; border: 0 !important; font-size: 1.3em !important; }
.pg-screen-body { flex: 1; overflow: auto; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: 30%; }
.pg-history { display: flex; flex-direction: column; gap: 0.6em; max-height: 100%; overflow: auto; }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 5em; }
.pg-slot-title { font-size: 0.8em; opacity: 0.85; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.5; font-size: 0.65em; }
.pg-setting { display: grid; grid-template-columns: 30% 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.7em; }
.pg-setting input { width: 100%; accent-color: var(--pg-accent); }
.pg-setting-val { opacity: 0.7; }
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
.pg-screen.pg-main { background-color: #000; justify-content: center; }
.pg-main-panel {
display: flex;
flex-direction: column;
gap: 0.5em;
width: 32%;
padding: 2em;
background: rgba(0, 0, 0, 0.6);
border-radius: 0.5em;
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
.pg-main-panel button { text-align: left; }
+470
View File
@@ -0,0 +1,470 @@
// A small evaluator for the Python subset emitted by the converter.
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
import type { Expr, PyStmt } from "./types";
export class PyDict {
map = new Map<any, any>();
constructor(entries?: Iterable<[any, any]>) {
if (entries) for (const [k, v] of entries) this.map.set(key(k), v);
}
get(k: any) {
return this.map.get(key(k));
}
set(k: any, v: any) {
this.map.set(key(k), v);
}
has(k: any) {
return this.map.has(key(k));
}
toJSON() {
return { __dict__: [...this.map.entries()] };
}
}
// tuples used as dict keys must compare by value
function key(k: any) {
return Array.isArray(k) ? JSON.stringify(k) : k;
}
export class JumpSignal {
constructor(public kind: "jump" | "call", public target: string) {}
}
class BreakSignal {}
class ContinueSignal {}
export type Builtin = ((...args: any[]) => any) & { __py?: true };
export function truthy(v: any): boolean {
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
if (Array.isArray(v)) return v.length > 0;
if (v instanceof PyDict) return v.map.size > 0;
return true;
}
export function pyEq(a: any, b: any): boolean {
if (a === b) return true;
if (a === null || b === null || a === undefined || b === undefined) return a == b;
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
if (typeof a === "boolean" || typeof b === "boolean") return Number(a) === Number(b);
return false;
}
export function pyStr(v: any): string {
if (v === null || v === undefined) return "None";
if (v === true) return "True";
if (v === false) return "False";
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
if (v instanceof PyDict)
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
return String(v);
}
function pyRepr(v: any): string {
return typeof v === "string" ? `'${v}'` : pyStr(v);
}
function pyMod(a: number, b: number) {
return ((a % b) + b) % b;
}
function percentFormat(fmt: string, args: any): string {
const list = Array.isArray(args) ? args : [args];
let i = 0;
return fmt.replace(/%(\([^)]*\))?[-+ 0#]*\d*(\.\d+)?([sdifr%])/g, (m, name, prec, conv) => {
if (conv === "%") return "%";
const v = name && args instanceof PyDict ? args.get(name.slice(1, -1)) : list[i++];
if (conv === "d" || conv === "i") return String(Math.trunc(Number(v)));
if (conv === "f") return Number(v).toFixed(prec ? Number(prec.slice(1)) : 6);
void m;
return pyStr(v);
});
}
export function binop(op: string, a: any, b: any): any {
switch (op) {
case "+":
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
if (typeof a === "string" || typeof b === "string") {
if (typeof a !== typeof b) throw new TypeError("str + не-str");
return a + b;
}
return a + b;
case "-":
return a - b;
case "*":
if (typeof a === "string" && typeof b === "number") return a.repeat(Math.max(0, b));
if (Array.isArray(a) && typeof b === "number") return Array.from({ length: b }, () => a).flat();
return a * b;
case "/":
return a / b;
case "//":
return Math.floor(a / b);
case "%":
if (typeof a === "string") return percentFormat(a, b);
return pyMod(a, b);
case "**":
return a ** b;
case "&":
return a & b;
case "|":
return a | b;
case "^":
return a ^ b;
case "<<":
return a << b;
case ">>":
return a >> b;
}
throw new Error(`оператор ${op}`);
}
function contains(container: any, item: any): boolean {
if (typeof container === "string") return container.includes(item);
if (Array.isArray(container)) return container.some((x) => pyEq(x, item));
if (container instanceof PyDict) return container.has(item);
if (container && typeof container === "object") return item in container;
return false;
}
function compare(op: string, a: any, b: any): boolean {
switch (op) {
case "==":
return pyEq(a, b);
case "!=":
return !pyEq(a, b);
case "<":
return a < b;
case "<=":
return a <= b;
case ">":
return a > b;
case ">=":
return a >= b;
case "in":
return contains(b, a);
case "not in":
return !contains(b, a);
case "is":
return a === b || (a == null && b == null);
case "is not":
return !(a === b || (a == null && b == null));
}
throw new Error(`сравнение ${op}`);
}
function index(len: number, i: number) {
return i < 0 ? len + i : i;
}
function methodOf(obj: any, name: string): any {
if (Array.isArray(obj)) {
const m: Record<string, Builtin> = {
append: (x) => void obj.push(x),
extend: (xs) => void obj.push(...xs),
insert: (i, x) => void obj.splice(i, 0, x),
pop: (i = -1) => obj.splice(index(obj.length, i), 1)[0],
remove: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i < 0) throw new Error("list.remove: нет элемента");
obj.splice(i, 1);
},
index: (x) => obj.findIndex((y) => pyEq(y, x)),
count: (x) => obj.filter((y) => pyEq(y, x)).length,
reverse: () => void obj.reverse(),
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
clear: () => void obj.splice(0),
copy: () => [...obj],
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
discard: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i >= 0) obj.splice(i, 1);
},
};
return m[name];
}
if (obj instanceof PyDict) {
const m: Record<string, Builtin> = {
get: (k, d = null) => (obj.has(k) ? obj.get(k) : d),
keys: () => [...obj.map.keys()],
values: () => [...obj.map.values()],
items: () => [...obj.map.entries()],
pop: (k, d = null) => {
const v = obj.has(k) ? obj.get(k) : d;
obj.map.delete(key(k));
return v;
},
setdefault: (k, d = null) => {
if (!obj.has(k)) obj.set(k, d);
return obj.get(k);
},
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()),
};
return m[name];
}
if (typeof obj === "string") {
const m: Record<string, Builtin> = {
lower: () => obj.toLowerCase(),
upper: () => obj.toUpperCase(),
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
title: () => obj.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase()),
strip: () => obj.trim(),
lstrip: () => obj.trimStart(),
rstrip: () => obj.trimEnd(),
startswith: (p) => obj.startsWith(p),
endswith: (p) => obj.endsWith(p),
replace: (a, b) => obj.split(a).join(b),
split: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
join: (xs) => xs.map(pyStr).join(obj),
format: (...args) => {
let i = 0;
return obj.replace(/\{(\d*)\}/g, (_, n) => pyStr(args[n === "" ? i++ : Number(n)]));
},
count: (s) => obj.split(s).length - 1,
find: (s) => obj.indexOf(s),
isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj),
};
return m[name];
}
return undefined;
}
export interface Scope {
get(name: string): any;
has(name: string): boolean;
set(name: string, value: any): void;
del(name: string): void;
}
export class Python {
locals: Map<string, any>[] = [];
warned = new Set<string>();
constructor(public scope: Scope) {}
warnOnce(msg: string) {
if (!this.warned.has(msg)) {
this.warned.add(msg);
console.warn("[pgvn]", msg);
}
}
lookup(name: string): any {
for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.scope.has(name)) return this.scope.get(name);
this.warnOnce(`неизвестное имя ${name}`);
return null;
}
eval(e: Expr | null | undefined): any {
if (!e) return null;
switch (e.k) {
case "const":
return e.v;
case "name":
return this.lookup(e.id);
case "attr": {
const o = this.eval(e.o);
if (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`);
return null;
}
const m = methodOf(o, e.a);
if (m) return m;
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
return v === undefined ? null : v;
}
case "sub": {
const o = this.eval(e.o);
const i = this.eval(e.i);
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null;
}
case "slice": {
const o = this.eval(e.o);
const lo = e.lo ? this.eval(e.lo) : undefined;
const hi = e.hi ? this.eval(e.hi) : undefined;
return o.slice(lo, hi);
}
case "bin":
return binop(e.op, this.eval(e.l), this.eval(e.r));
case "bool": {
let v: any = null;
for (const x of e.v) {
v = this.eval(x);
if (e.op === "and" ? !truthy(v) : truthy(v)) return v;
}
return v;
}
case "unary": {
const v = this.eval(e.v);
if (e.op === "not") return !truthy(v);
if (e.op === "-") return -v;
if (e.op === "~") return ~v;
return +v;
}
case "cmp": {
let left = this.eval(e.l);
for (let i = 0; i < e.ops.length; i++) {
const right = this.eval(e.r[i]);
if (!compare(e.ops[i], left, right)) return false;
left = right;
}
return true;
}
case "if":
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
case "list":
case "tuple":
return e.v.map((x: Expr) => this.eval(x));
case "dict":
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
case "fmt":
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
case "comp": {
const out: any[] = [];
const frame = new Map<string, any>();
this.locals.push(frame);
try {
for (const item of iterate(this.eval(e.it))) {
this.bind(e.t, item, frame);
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
}
} finally {
this.locals.pop();
}
return out;
}
case "call": {
const f = this.eval(e.f);
const args = e.a.map((x: Expr) => this.eval(x));
const kw: Record<string, any> = {};
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
if (typeof f !== "function") {
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
return null;
}
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
}
}
this.warnOnce(`неизвестное выражение ${e.k}`);
return null;
}
bind(t: Expr, value: any, frame?: Map<string, any>) {
switch (t.k) {
case "name":
if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value);
return;
case "tuple": {
const items = [...iterate(value)];
t.v.forEach((sub: Expr, i: number) => this.bind(sub, items[i], frame));
return;
}
case "attr": {
const o = this.eval(t.o);
if (o instanceof PyDict) o.set(t.a, value);
else if (o) o[t.a] = value;
return;
}
case "sub": {
const o = this.eval(t.o);
const i = this.eval(t.i);
if (o instanceof PyDict) o.set(i, value);
else if (Array.isArray(o)) o[index(o.length, i)] = value;
else if (o) o[i] = value;
return;
}
}
this.warnOnce(`присваивание в ${t.k}`);
}
exec(stmts: PyStmt[]) {
let guard = 0;
for (const s of stmts) {
switch (s.op) {
case "set":
this.bind(s.t, this.eval(s.v));
break;
case "aug":
this.bind(s.t, binop(s.o, this.eval(s.t), this.eval(s.v)));
break;
case "eval":
this.eval(s.e);
break;
case "if":
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
break;
case "while":
while (truthy(this.eval(s.c))) {
if (++guard > 100000) throw new Error("бесконечный цикл в python");
if (this.loopBody(s.body)) break;
}
break;
case "for":
for (const item of [...iterate(this.eval(s.it))]) {
this.bind(s.t, item);
if (this.loopBody(s.body)) break;
}
break;
case "del":
if (s.t.k === "name") this.scope.del(s.t.id);
else if (s.t.k === "sub") {
const o = this.eval(s.t.o);
const i = this.eval(s.t.i);
if (o instanceof PyDict) o.map.delete(key(i));
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
}
break;
case "break":
throw new BreakSignal();
case "continue":
throw new ContinueSignal();
}
}
}
/** Runs a loop body; returns true on break. */
private loopBody(body: PyStmt[]): boolean {
try {
this.exec(body);
} catch (err) {
if (err instanceof BreakSignal) return true;
if (err instanceof ContinueSignal) return false;
throw err;
}
return false;
}
}
export function* iterate(v: any): Iterable<any> {
if (Array.isArray(v) || typeof v === "string") yield* v;
else if (v instanceof PyDict) yield* v.map.keys();
else if (v && typeof v[Symbol.iterator] === "function") yield* v;
}
/** JSON (de)serialisation of the store, keeping PyDict instances. */
export function encodeValue(v: any): any {
return JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "function" ? undefined : x)));
}
export function decodeValue(v: any): any {
if (Array.isArray(v)) return v.map(decodeValue);
if (v && typeof v === "object") {
if (Array.isArray(v.__dict__))
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
const out: Record<string, any> = {};
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
return out;
}
return v;
}
+287
View File
@@ -0,0 +1,287 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform.
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
};
export interface ResolvedImage {
def: ImageDef;
name: string[];
}
export interface RendererHost {
url(file: string): string | null;
resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null;
}
interface Entry extends ShownImage {
el: HTMLElement;
}
export class Renderer {
readonly stage: HTMLDivElement;
readonly master: HTMLDivElement;
private overlay: HTMLDivElement;
private shown = new Map<string, Entry>();
private committed: HTMLElement | null = null;
fast = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) {
this.stage = document.createElement("div");
this.stage.className = "pg-stage";
this.stage.style.width = `${width}px`;
this.stage.style.height = `${height}px`;
this.master = document.createElement("div");
this.master.className = "pg-layer";
this.overlay = document.createElement("div");
this.overlay.className = "pg-layer pg-transition";
this.stage.append(this.master, this.overlay);
parent.append(this.stage);
}
fit(container: HTMLElement) {
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove();
this.shown.clear();
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? [];
const el = this.build(img.def);
el.classList.add("pg-image");
this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
if (prev) {
prev.el.replaceWith(el);
} else {
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
e.el.remove();
this.shown.delete(tag);
}
isShown(tag: string) {
return this.shown.has(tag);
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null;
}
snapshot(): ShownImage[] {
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
}
// --- displayables ------------------------------------------------------------
private build(def: ImageDef, depth = 0): HTMLElement {
if (depth > 8) return document.createElement("div");
if (def.ref) {
const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
}
if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1);
}
if (def.color) {
const d = document.createElement("div");
d.style.background = def.color;
d.style.width = `${this.width}px`;
d.style.height = `${this.height}px`;
return d;
}
if (def.composite) {
const d = document.createElement("div");
d.style.position = "relative";
if (def.size) {
d.style.width = `${def.size[0]}px`;
d.style.height = `${def.size[1]}px`;
}
for (const layer of def.composite) {
const child = this.build(layer.img, depth + 1);
child.style.position = "absolute";
child.style.left = `${layer.pos[0]}px`;
child.style.top = `${layer.pos[1]}px`;
d.append(child);
}
return d;
}
if (def.movie) {
const v = document.createElement("video");
const src = this.host.url(def.movie);
if (src) v.src = src;
v.autoplay = true;
v.loop = true;
v.muted = true;
v.playsInline = true;
return v;
}
if (def.text !== undefined) {
const d = document.createElement("div");
d.className = "pg-show-text";
d.textContent = def.text;
return d;
}
if (def.file) {
const img = document.createElement("img");
img.draggable = false;
img.decoding = "async";
const src = this.host.url(def.file);
if (src) img.src = src;
else console.warn("[pgvn] нет файла", def.file);
if (def.props?.size) {
img.style.width = `${def.props.size[0]}px`;
img.style.height = `${def.props.size[1]}px`;
}
return img;
}
return document.createElement("div");
}
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of at) {
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
if (spec.props) Object.assign(p, spec.props);
}
normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
const ax = anchor(p.xanchor);
const ay = anchor(p.yanchor);
el.style.transformOrigin = `${ax} ${ay}`;
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
if (ax.endsWith("px") || ay.endsWith("px"))
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.filter) el.style.filter = `${p.filter}(1)`;
}
// --- transitions ---------------------------------------------------------------
/** Remember the current picture as the starting point of the next transition. */
commit() {
this.committed = this.master.cloneNode(true) as HTMLElement;
}
async transition(t: Transition | null | undefined): Promise<void> {
const before = this.committed;
this.commit();
if (!t || !before || this.fast) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
this.overlay.append(old);
try {
if (t.t === "fade") {
const color = document.createElement("div");
color.className = "pg-fill";
color.style.background = t.color ?? "#000";
color.style.opacity = "0";
this.overlay.append(color);
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
old.remove();
if (t.hold) await sleep(t.hold);
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
color.remove();
} else if (t.t === "wipe") {
const dir = t.dir ?? "right";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
} else if (t.t === "slide") {
const dir = t.dir ?? "left";
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
}
} finally {
old.remove();
}
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y";
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3);
}
preload(def: ImageDef | null) {
if (def?.file) {
const src = this.host.url(def.file);
if (src) new Image().src = src;
}
}
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
p.xanchor = asFloat(p.xanchor);
p.yanchor = asFloat(p.yanchor);
p.xpos = asFloat(p.xpos);
p.ypos = asFloat(p.ypos);
}
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
function asFloat(v: any): number {
return typeof v === "number" ? v : 0;
}
export function sleep(seconds: number) {
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
}
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
return new Promise((resolve) => {
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve();
a.oncancel = () => resolve();
});
}
+187
View File
@@ -0,0 +1,187 @@
// Ren'Py text tags → styled segments, plus [variable] interpolation.
import { pyStr } from "./python";
export interface Style {
b?: boolean;
i?: boolean;
u?: boolean;
s?: boolean;
color?: string;
size?: string;
}
export type Segment =
| { kind: "text"; text: string; style: Style }
| { kind: "wait"; seconds: number | null } // {w}, {w=1}, {p}
| { kind: "fast" };
export interface ParsedText {
segments: Segment[];
noWait: boolean; // {nw}
length: number; // number of visible characters
}
export function interpolate(text: string, lookup: (expr: string) => any): string {
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
if (m === "[[") return "[";
let src = expr!.trim();
let flags = "";
const bang = src.lastIndexOf("!");
if (bang > 0 && /^[a-z]+$/.test(src.slice(bang + 1))) {
flags = src.slice(bang + 1);
src = src.slice(0, bang);
}
let v = pyStr(lookup(src));
if (flags.includes("u")) v = v.toUpperCase();
if (flags.includes("l")) v = v.toLowerCase();
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1);
return v;
});
}
export function parseText(text: string): ParsedText {
const segments: Segment[] = [];
const stack: Style[] = [{}];
let noWait = false;
let length = 0;
const push = (t: string) => {
if (!t) return;
length += [...t].length;
segments.push({ kind: "text", text: t, style: { ...stack[stack.length - 1] } });
};
let i = 0;
let buf = "";
while (i < text.length) {
const c = text[i];
if (c === "{") {
if (text[i + 1] === "{") {
buf += "{";
i += 2;
continue;
}
const end = text.indexOf("}", i);
if (end < 0) {
buf += text.slice(i);
break;
}
push(buf);
buf = "";
const tag = text.slice(i + 1, end);
i = end + 1;
const [name, value] = splitTag(tag);
const top = stack[stack.length - 1];
if (name.startsWith("/")) {
if (stack.length > 1) stack.pop();
continue;
}
switch (name) {
case "b":
case "i":
case "u":
case "s":
stack.push({ ...top, [name]: true });
break;
case "color":
stack.push({ ...top, color: value });
break;
case "size":
stack.push({ ...top, size: value });
break;
case "w":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
break;
case "p":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
push("\n");
break;
case "nw":
noWait = true;
break;
case "fast":
segments.push({ kind: "fast" });
break;
case "space":
push(" ");
break;
case "vspace":
push("\n");
break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
stack.push({ ...top }); // styling we ignore but must still balance
break;
default:
// {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags
break;
}
continue;
}
buf += c;
i++;
}
push(buf);
return { segments, noWait, length };
}
function splitTag(tag: string): [string, string] {
const eq = tag.indexOf("=");
return eq < 0 ? [tag.trim(), ""] : [tag.slice(0, eq).trim(), tag.slice(eq + 1).trim()];
}
/** Render the first `visible` characters of parsed text into `el`. */
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
el.textContent = "";
let left = visible;
for (const seg of parsed.segments) {
if (seg.kind !== "text") continue;
if (left <= 0) break;
const chars = [...seg.text];
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
left -= chars.length;
const st = seg.style;
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
el.append(shown);
continue;
}
const span = document.createElement("span");
span.textContent = shown;
if (st.b) span.style.fontWeight = "bold";
if (st.i) span.style.fontStyle = "italic";
if (st.u || st.s) span.style.textDecoration = [st.u && "underline", st.s && "line-through"].filter(Boolean).join(" ");
if (st.color) span.style.color = st.color;
if (st.size) span.style.fontSize = sizeValue(st.size, baseSize);
el.append(span);
}
}
function sizeValue(v: string, base: number): string {
if (v.startsWith("+") || v.startsWith("-")) return `${base + Number(v)}px`;
if (v.startsWith("*")) return `${base * Number(v.slice(1))}px`;
return `${Number(v)}px`;
}
/** Character offsets at which {w}/{p} pauses happen, with their durations. */
export function waitPoints(parsed: ParsedText): { at: number; seconds: number | null }[] {
const out: { at: number; seconds: number | null }[] = [];
let pos = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "wait") out.push({ at: pos, seconds: seg.seconds });
}
return out;
}
export function fastPoint(parsed: ParsedText): number {
let pos = 0;
let fast = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "fast") fast = pos;
}
return fast;
}
export function plainText(parsed: ParsedText): string {
return parsed.segments.map((s) => (s.kind === "text" ? s.text : "")).join("");
}
+108
View File
@@ -0,0 +1,108 @@
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
export type Expr = { k: string; [key: string]: any };
export type PyStmt = { op: string; [key: string]: any };
export interface Transition {
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
d?: number;
out?: number;
hold?: number;
in?: number;
color?: string;
dir?: string;
name?: string;
}
export interface ImageDef {
file?: string;
color?: string;
ref?: string;
movie?: string;
expr?: Expr;
switch?: [Expr, ImageDef][];
composite?: { pos: [number, number]; img: ImageDef }[];
size?: [number, number] | null;
text?: string;
props?: Record<string, any>;
}
export interface AtSpec {
pos?: string;
name?: string;
props?: Record<string, any>;
}
export interface CharacterDef {
name?: string | null;
nameExpr?: Expr;
color?: string;
whatColor?: string;
whatPrefix?: string;
whatSuffix?: string;
whoPrefix?: string;
whoSuffix?: string;
image?: string;
nvl?: boolean;
whatItalic?: boolean;
whatBold?: boolean;
}
export type Node = { op: string; [key: string]: any };
export interface Label {
name: string;
body: Node[];
}
export interface ScriptModule {
file: string;
labels: Label[];
}
export interface GameData {
format: string;
title: string | null;
resolution: [number, number];
entry: string;
scripts: string[];
characters: Record<string, CharacterDef>;
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
transitions: Record<string, Transition | null>;
audio: Record<string, string>;
files: Record<string, string>;
defaults: [string, Expr][];
init: PyStmt[];
ui: Record<string, any>;
}
export interface Manifest {
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
label: string;
path: number[];
}
export interface ShownImage {
tag: string;
name: string[];
at: AtSpec[];
zorder: number;
layer?: string;
}
export interface SaveData {
v: 1;
cur: Frame;
stack: Frame[];
store: any;
scene: ShownImage[];
music: { file: string; loop: boolean } | null;
window: boolean;
menuSeen?: string[];
text?: string;
}
+153
View File
@@ -0,0 +1,153 @@
:root {
--bg: #0f1117;
--panel: #171a23;
--panel-2: #1f2330;
--border: #2a2f3e;
--text: #e6e8ef;
--muted: #8b90a0;
--accent: #ff9f43;
--danger: #ef5a5a;
--ok: #4cc38a;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
}
a { color: inherit; text-decoration: none; }
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; }
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
input, select, textarea, button, .button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.7rem;
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button, .button { cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; }
button.danger { color: var(--danger); }
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); }
button.link:hover { color: var(--text); }
.muted { color: var(--muted); }
.small { font-size: 0.85em; }
.error { color: var(--danger); margin: 0.5rem 0; }
.ok { color: var(--ok); }
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; }
.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; }
.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); }
.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; }
.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; }
.inline-form input { flex: 1; min-width: 200px; }
.layout { min-height: 100%; display: flex; flex-direction: column; }
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.7rem 1.5rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; }
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
.topbar nav a { color: var(--muted); }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 0.3rem; }
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
.page.narrow { max-width: 560px; }
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
.game-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); }
.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; }
.cover {
aspect-ratio: 16 / 10;
background: linear-gradient(135deg, #262b3b, #1a1d29);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--muted);
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); }
.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; }
.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; }
.description { white-space: pre-wrap; margin: 0; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.editor { display: flex; flex-direction: column; gap: 0.6rem; }
.versions { display: flex; flex-direction: column; gap: 0.6rem; }
.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; }
.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; }
.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); }
.status-ready { color: var(--ok); }
.status-failed { color: var(--danger); }
.status-queued, .status-converting, .status-uploading { color: var(--accent); }
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; }
.report table { border-collapse: collapse; margin-top: 0.4rem; }
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; }
.report summary { cursor: pointer; margin: 0.3rem 0; }
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; }
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 500; }
.table tr.inactive td { opacity: 0.55; }
.center-cell { text-align: center !important; }
.roles th:not(:first-child) { text-align: center; }
.issued { border-color: var(--accent); margin-bottom: 1rem; }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; }
.play { position: fixed; inset: 0; background: #000; }
.play-host { position: absolute; inset: 0; }
.play-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
color: var(--muted);
pointer-events: none;
}
.play-status button { pointer-events: auto; }
@media (max-width: 640px) {
.page { padding: 1rem; }
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/pages/changecredentials.tsx","./src/pages/gamepage.tsx","./src/pages/library.tsx","./src/pages/login.tsx","./src/pages/newgame.tsx","./src/pages/play.tsx","./src/pages/roles.tsx","./src/pages/users.tsx","./src/player/audio.ts","./src/player/engine.ts","./src/player/index.ts","./src/player/python.ts","./src/player/renderer.ts","./src/player/text.ts","./src/player/types.ts"],"version":"5.6.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// In dev, API and blobs are proxied to a backend on :8000 (or set VITE_BACKEND).
const backend = process.env.VITE_BACKEND ?? "http://localhost:8000";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": backend,
"/blobs": backend,
},
},
});