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

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

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

No files matched your search

View File
Whitespace-only changes.
+74
View File
@@ -0,0 +1,74 @@
# Sample game used by converter tests and the end-to-end check.
define e = Character("Эйлин", color="#c8ffc8")
define m = Character("[player_name]", color="#c8c8ff")
define n = Character(None, kind=nvl)
define flash = Fade(0.1, 0.0, 0.5, color="#fff")
define audio.theme = "audio/theme.ogg"
default points = 0
default player_name = "Гость"
default inventory = []
image bg room = "bg room.png"
image eileen happy = "eileen happy.png"
image eileen sad = Transform("eileen happy.png", alpha=0.7)
image black = "#000"
transform slightleft:
xalign 0.3 yalign 1.0
init python:
def helper():
return 1
bonus = 5
persistent.seen_intro = False
label start:
scene bg room with fade
play music theme fadein 1.0
"Добро пожаловать в {b}тестовую{/b} новеллу."
show eileen happy at left with dissolve
e "Привет! Как тебя зовут?"
$ player_name = "Алекс"
m "Меня зовут [player_name]."
menu:
e "Что будем делать?"
"Пойдём гулять" if points >= 0:
$ points += 1
$ inventory.append("map")
jump walk
"Останемся дома":
$ points -= 1
call stay_home
e "Вернулись из call."
jump ending
label walk:
show eileen sad at slightleft
e sad "На улице дождь..."
with flash
$ renpy.pause(0.5)
if points > 0 and "map" in inventory:
e "Но у нас есть карта, [points] очков!"
elif points == 0:
e "Ничего."
else:
e "Плохо."
jump ending
label stay_home:
hide eileen with dissolve
"Тихий вечер дома."
$ i = 0
while i < 2:
$ i += 1
"Тик-так [i]."
return
label ending:
stop music fadeout 1.0
scene black with dissolve
centered "Конец. Очки: [points]"
return
+72
View File
@@ -0,0 +1,72 @@
"""Build a small Ren'Py-like game archive for tests and manual checks.
python -m tests.make_sample out.zip
"""
import io
import math
import struct
import sys
import wave
import zipfile
from pathlib import Path
from PIL import Image, ImageDraw
from app.converter.rpa import write_rpa
FIXTURES = Path(__file__).parent / "fixtures" / "sample"
def _png(size, color, text="", alpha=False) -> bytes:
img = Image.new("RGBA" if alpha else "RGB", size, (0, 0, 0, 0) if alpha else color)
d = ImageDraw.Draw(img)
if alpha:
w, h = size
d.ellipse((w * 0.2, h * 0.05, w * 0.8, h * 0.45), fill=color)
d.rectangle((w * 0.25, h * 0.45, w * 0.75, h), fill=color)
else:
for y in range(0, size[1], 40):
d.line((0, y, size[0], y), fill=tuple(min(255, c + 25) for c in color), width=2)
if text:
d.text((20, 20), text, fill=(255, 255, 255, 255))
out = io.BytesIO()
img.save(out, "PNG")
return out.getvalue()
def _wav(seconds=2.0, freq=440) -> bytes:
out = io.BytesIO()
with wave.open(out, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(22050)
frames = b"".join(struct.pack("<h", int(8000 * math.sin(2 * math.pi * freq * i / 22050)))
for i in range(int(22050 * seconds)))
w.writeframes(frames)
return out.getvalue()
def build(path: Path) -> Path:
script = (FIXTURES / "script.rpy").read_bytes()
options = 'define config.name = "Тестовая новелла"\ninit python:\n gui.init(1280, 720)\n'
rpa_path = path.with_suffix(".rpa.tmp")
write_rpa(rpa_path, {
"script.rpy": script,
"images/bg room.png": _png((1280, 720), (60, 70, 110), "bg room"),
"images/eileen happy.png": _png((400, 650), (220, 160, 170), alpha=True),
})
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("SampleGame-1.0-pc/game/archive.rpa", rpa_path.read_bytes())
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options)
zf.writestr("SampleGame-1.0-pc/game/audio/theme.wav", _wav())
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
_png((1280, 720), (30, 30, 50), "Main menu"))
zf.writestr("SampleGame-1.0-pc/renpy/common/00start.rpy", "label _start:\n return\n")
zf.writestr("SampleGame-1.0-pc/SampleGame.exe", b"MZ")
rpa_path.unlink()
return path
if __name__ == "__main__":
print(build(Path(sys.argv[1] if len(sys.argv) > 1 else "sample.zip")))
+81
View File
@@ -0,0 +1,81 @@
import json
import shutil
import pytest
from app.blobs import read_bytes
from app.config import get_settings
from app.converter import pyexpr
from app.converter.pipeline import convert_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from tests.make_sample import build
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
yield tmp_path / "data"
get_settings.cache_clear()
def parse(src: str) -> ScriptData:
d = ScriptData()
RpyParser(d, "t.rpy").parse(src)
return d
def test_expr_translation():
e = pyexpr.parse_expr("a >= 1 and 'x' in inv or not flag")
assert e["k"] == "bool" and e["op"] == "or"
with pytest.raises(pyexpr.Unsupported):
pyexpr.parse_expr("lambda: 1")
def test_say_variants():
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"]
assert body[0] == {"op": "say", "what": "narration"}
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered"
def test_string_whitespace_and_escapes():
d = parse('label start:\n "multi\n line \\"q\\" {b}x{/b}"\n')
assert d.modules["t.rpy"][0]["body"][0]["what"] == 'multi line "q" {b}x{/b}'
def test_if_chain_and_menu():
d = parse("label start:\n if a:\n jump x\n elif b:\n pass\n else:\n"
" return\n menu:\n \"A\" if c:\n jump y\n \"B\":\n"
" pass\n")
body = d.modules["t.rpy"][0]["body"]
assert [b["c"] is None for b in body[0]["branches"]] == [False, False, True]
assert [i["text"] for i in body[1]["items"]] == ["A", "B"]
def test_unsupported_python_is_reported_not_fatal():
d = parse("init python:\n def f():\n pass\n x = 1\nlabel start:\n $ y = x\n")
assert d.warnings and d.init_code[0]["op"] == "set"
def test_full_pipeline(data_dir, tmp_path):
archive = build(tmp_path / "sample.zip")
steps = []
result = convert_archive(archive, "sample.zip", tmp_path / "work", steps.append)
files = result.manifest["files"]
assert "game.json" in files and "script/script.json" in files
assert "images/bg room.webp" in files
game = json.loads(read_bytes(files["game.json"]["hash"]))
assert game["title"] == "Тестовая новелла"
assert game["resolution"] == [1280, 720]
assert game["images"]["bg room"]["file"] == "images/bg room.webp"
assert game["images"]["eileen sad"]["props"]["alpha"] == 0.7
assert "e" in game["characters"]
assert result.cover_source is not None
script = json.loads(read_bytes(files["script/script.json"]["hash"]))
assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"]
if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files