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:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
sample.zip
|
||||||
@@ -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` — такие места
|
||||||
|
перечислены в отчёте конвертации у версии.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -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='*'"]
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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__}")
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"})
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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"],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
+74
@@ -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
|
||||||
@@ -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")))
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
|
* Сборщик мусора блобов по расписанию.
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1784
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)); }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user