commit 364b3120642fbaf282df5bf42bb9eaa3cc777eee Author: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun Oct 25 02:41:32 2026 +0300 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f58be5 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..862f0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +data/ +node_modules/ +dist/ +__pycache__/ +.pytest_cache/ +*.pyc +sample.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c393e0 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# PGames — локальная библиотека визуальных новелл + +Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит +скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) +и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры +может быть несколько версий (форков) от разных пользователей; версии делят +общие ассеты, поэтому форк почти не занимает места. + +Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Установка на VM + +```bash +git clone pgames && cd pgames +cp .env.example .env +# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли +docker compose up -d --build +``` + +Приложение слушает `http://: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` — такие места +перечислены в отчёте конвертации у версии. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c1ab235 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +__pycache__ +.pytest_cache +*.pyc +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..02525f8 --- /dev/null +++ b/backend/Dockerfile @@ -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='*'"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..180bff8 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/blobs.py b/backend/app/blobs.py new file mode 100644 index 0000000..b333d89 --- /dev/null +++ b/backend/app/blobs.py @@ -0,0 +1,69 @@ +"""Content-addressed blob store. + +A blob id is ```` (e.g. ``3fa1….webp``); the file lives at +``blobs/3f/a1/``. 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() diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py new file mode 100644 index 0000000..b57d579 --- /dev/null +++ b/backend/app/bootstrap.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..edb6d39 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/converter/__init__.py b/backend/app/converter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/converter/archive.py b/backend/app/converter/archive.py new file mode 100644 index 0000000..efba0f4 --- /dev/null +++ b/backend/app/converter/archive.py @@ -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 diff --git a/backend/app/converter/assets.py b/backend/app/converter/assets.py new file mode 100644 index 0000000..83cd62c --- /dev/null +++ b/backend/app/converter/assets.py @@ -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 diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py new file mode 100644 index 0000000..89d3028 --- /dev/null +++ b/backend/app/converter/pipeline.py @@ -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 diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py new file mode 100644 index 0000000..b5075ca --- /dev/null +++ b/backend/app/converter/pyexpr.py @@ -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__}") diff --git a/backend/app/converter/rpa.py b/backend/app/converter/rpa.py new file mode 100644 index 0000000..27a49ed --- /dev/null +++ b/backend/app/converter/rpa.py @@ -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()) diff --git a/backend/app/converter/rpy_lexer.py b/backend/app/converter/rpy_lexer.py new file mode 100644 index 0000000..e4254b1 --- /dev/null +++ b/backend/app/converter/rpy_lexer.py @@ -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() diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py new file mode 100644 index 0000000..fe81659 --- /dev/null +++ b/backend/app/converter/rpy_parser.py @@ -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} diff --git a/backend/app/converter/rpyc.py b/backend/app/converter/rpyc.py new file mode 100644 index 0000000..08b8cb1 --- /dev/null +++ b/backend/app/converter/rpyc.py @@ -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 diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..137ca0e --- /dev/null +++ b/backend/app/db.py @@ -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 diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..30f2b17 --- /dev/null +++ b/backend/app/deps.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9e47f3e --- /dev/null +++ b/backend/app/main.py @@ -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"}) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..63b90eb --- /dev/null +++ b/backend/app/models.py @@ -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() + ) diff --git a/backend/app/permissions.py b/backend/app/permissions.py new file mode 100644 index 0000000..677b6d7 --- /dev/null +++ b/backend/app/permissions.py @@ -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"], +} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..70105f1 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py new file mode 100644 index 0000000..a302233 --- /dev/null +++ b/backend/app/routers/games.py @@ -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] diff --git a/backend/app/routers/roles.py b/backend/app/routers/roles.py new file mode 100644 index 0000000..80d1fef --- /dev/null +++ b/backend/app/routers/roles.py @@ -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() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..e76fcf7 --- /dev/null +++ b/backend/app/routers/users.py @@ -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() diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py new file mode 100644 index 0000000..d5fc7f5 --- /dev/null +++ b/backend/app/routers/versions.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..55c6b92 --- /dev/null +++ b/backend/app/schemas.py @@ -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] diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..f420ae2 --- /dev/null +++ b/backend/app/security.py @@ -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 diff --git a/backend/app/uploads.py b/backend/app/uploads.py new file mode 100644 index 0000000..b90ece9 --- /dev/null +++ b/backend/app/uploads.py @@ -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) diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..eab6ec5 --- /dev/null +++ b/backend/app/worker.py @@ -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()) diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..f3040b8 --- /dev/null +++ b/backend/migrations/env.py @@ -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()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..1495b79 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -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"} diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..cf82aa8 --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..25bc10b --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/fixtures/sample/script.rpy b/backend/tests/fixtures/sample/script.rpy new file mode 100644 index 0000000..abf042a --- /dev/null +++ b/backend/tests/fixtures/sample/script.rpy @@ -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 diff --git a/backend/tests/make_sample.py b/backend/tests/make_sample.py new file mode 100644 index 0000000..35fd13b --- /dev/null +++ b/backend/tests/make_sample.py @@ -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(" 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"))) diff --git a/backend/tests/test_converter.py b/backend/tests/test_converter.py new file mode 100644 index 0000000..3ed5cd6 --- /dev/null +++ b/backend/tests/test_converter.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4271af --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f0a7a27 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,133 @@ +# pgames-manager — архитектура + +Локальная библиотека визуальных новелл (сначала Ren'Py). Игры не запускаются +оригинальным движком: архив разбирается на сервере, скрипт и ассеты +перекладываются в собственный формат **PGVN**, а играется копия в нашем +веб-плеере. + +## Развёртывание + +``` +Proxmox host ── Caddy (TLS, домен) ──► http://: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/` — неизменяемые файлы, отдаются 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-рендер слоёв (`` с GPU-композицией), сцена масштабируется под + окно, переходы через CSS; +* текстбокс с эффектом печати, теги `{b} {i} {color} {size} {w} {p} {nw}`, + интерполяция `[var]`; +* меню выборов, история реплик, skip/auto, быстрые и слотовые сохранения + на сервере, громкость каналов, предзагрузка ближайших картинок. + +## Дорожная карта + +* Веб-редактор форка: правка скриптов и замена ассетов (манифест уже готов). +* Поддержка screens/ATL-трансформаций в IR. +* Другие движки (TyranoBuilder, RPG Maker MV) через тот же IR. +* Сборщик мусора блобов по расписанию. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f5d1e62 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d69f96b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PGames + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ced691e --- /dev/null +++ b/frontend/nginx.conf @@ -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/ -> /data/blobs/ab/cd/ + 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"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8dd317c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1784 @@ +{ + "name": "pgames-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pgames-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", + "integrity": "sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e36ce9d --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ad9f991 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 :

Нет доступа к этой странице.

; +} + +export function App() { + const { me, loading, logout, can } = useAuth(); + if (loading) return
Загрузка…
; + if (!me) return ; + if (me.must_change_password || me.must_change_username) return ; + + return ( + + } /> + +
+ PGames + +
+ {me.username} + · {me.role.name} + +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + } + /> +
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..ad23761 --- /dev/null +++ b/frontend/src/api.ts @@ -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 | 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(method: string, url: string, body?: unknown): Promise { + 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: (url: string) => request("GET", url), + post: (url: string, body?: unknown) => request("POST", url, body ?? {}), + put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), + patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), + del: (url: string) => request("DELETE", url), +}; + +/** Upload a raw body with progress (fetch has no upload progress). */ +export function uploadRaw(method: string, url: string, file: Blob, + onProgress: (fraction: number) => void): Promise { + 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]}`; +} diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..9aace4f --- /dev/null +++ b/frontend/src/auth.tsx @@ -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; + setMe: (me: Me | null) => void; + logout: () => Promise; + can: (perm: string) => boolean; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + setMe(await api.get("/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 ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("AuthProvider missing"); + return ctx; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a31c752 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + , +); diff --git a/frontend/src/pages/ChangeCredentials.tsx b/frontend/src/pages/ChangeCredentials.tsx new file mode 100644 index 0000000..db227a3 --- /dev/null +++ b/frontend/src/pages/ChangeCredentials.tsx @@ -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("/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 = ( +
+

{forced ? "Смена учётных данных" : "Аккаунт"}

+ {forced && ( +

+ {needUsername + ? "Это первый вход администратора. Задайте собственный логин и пароль." + : "Пароль был выдан администратором. Задайте свой пароль."} +

+ )} + + + + + {error &&
{error}
} + {done && !forced &&
Сохранено
} + + {forced && } +
+ ); + return forced ?
{form}
:
{form}
; +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx new file mode 100644 index 0000000..a55c899 --- /dev/null +++ b/frontend/src/pages/GamePage.tsx @@ -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 = { + uploading: "загружается", + queued: "в очереди", + converting: "конвертируется", + ready: "готова", + failed: "ошибка", +}; + +export function GamePage() { + const { gameId } = useParams(); + const navigate = useNavigate(); + const { me, can } = useAuth(); + const [game, setGame] = useState(null); + const [error, setError] = useState(""); + const [editing, setEditing] = useState(false); + + const load = useCallback(async () => { + try { + setGame(await api.get(`/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
{error}
; + if (!game) return
Загрузка…
; + + // the backend also lets uploaders edit only games they created + const canEdit = can("games.edit") || can("games.upload"); + const run = async (fn: () => Promise) => { + 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(); + 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) => [ + 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 ( +
+ ← Библиотека +
+
+ {game.cover_hash ? : {game.title[0]}} +
+
+ {editing ? ( + { setEditing(false); void load(); }} /> + ) : ( + <> +

{game.title}

+
{game.tags.map((t) => {t})}
+

{game.description || Нет описания}

+
+ {canEdit && } + {canEdit && ( + + )} + {can("games.upload") && ( + + )} + {can("games.delete") && ( + + )} +
+ + )} +
+
+ {error &&
{error}
} +

Версии

+
{renderTree(null, 0)}
+
+ ); +} + +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(undefined); + const toggleReport = async () => { + if (report !== undefined) setReport(undefined); + else setReport((await api.get(`/api/versions/${v.id}`)).report ?? null); + }; + return ( +
+
+
+ {props.depth > 0 && "↳ "}{v.name} + {STATUS[v.status]} +
+ {v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} + {v.progress && ` · ${v.progress}`} +
+
+
+ {v.status === "ready" && } + {v.status === "ready" && props.canFork && } + {(v.status === "ready" || v.status === "failed") && } + {v.status === "failed" && props.canManage && } + {props.canManage && v.status !== "converting" && } +
+
+ {report !== undefined && } +
+ ); +} + +function Report({ report }: { report: Record | null }) { + if (!report) return
Отчёта нет
; + if (report.error) return
{report.error}
; + return ( +
+
+ Скриптов: {report.scripts} (декомпилировано {report.decompiled}) + Меток: {report.labels} + Реплик: {report.stats?.say ?? 0} + Меню: {report.stats?.menu ?? 0} + Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video} + Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)} + Время: {report.durationSec} с +
+ {report.missingFiles?.length > 0 && ( +
+ Не найдены файлы ({report.missingFiles.length}) +
    {report.missingFiles.map((f: string) =>
  • {f}
  • )}
+
+ )} + {report.warnings?.length > 0 && ( +
+ Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)}) + + + {report.warnings.map((w: any) => ( + + + + + + ))} + +
{w.count}×{w.message}{w.example}
+
+ )} +
+ ); +} + +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 ( +
+ setTitle(e.target.value)} /> +