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

+470
View File
@@ -0,0 +1,470 @@
// A small evaluator for the Python subset emitted by the converter.
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
import type { Expr, PyStmt } from "./types";
export class PyDict {
map = new Map<any, any>();
constructor(entries?: Iterable<[any, any]>) {
if (entries) for (const [k, v] of entries) this.map.set(key(k), v);
}
get(k: any) {
return this.map.get(key(k));
}
set(k: any, v: any) {
this.map.set(key(k), v);
}
has(k: any) {
return this.map.has(key(k));
}
toJSON() {
return { __dict__: [...this.map.entries()] };
}
}
// tuples used as dict keys must compare by value
function key(k: any) {
return Array.isArray(k) ? JSON.stringify(k) : k;
}
export class JumpSignal {
constructor(public kind: "jump" | "call", public target: string) {}
}
class BreakSignal {}
class ContinueSignal {}
export type Builtin = ((...args: any[]) => any) & { __py?: true };
export function truthy(v: any): boolean {
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
if (Array.isArray(v)) return v.length > 0;
if (v instanceof PyDict) return v.map.size > 0;
return true;
}
export function pyEq(a: any, b: any): boolean {
if (a === b) return true;
if (a === null || b === null || a === undefined || b === undefined) return a == b;
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
if (typeof a === "boolean" || typeof b === "boolean") return Number(a) === Number(b);
return false;
}
export function pyStr(v: any): string {
if (v === null || v === undefined) return "None";
if (v === true) return "True";
if (v === false) return "False";
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
if (v instanceof PyDict)
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
return String(v);
}
function pyRepr(v: any): string {
return typeof v === "string" ? `'${v}'` : pyStr(v);
}
function pyMod(a: number, b: number) {
return ((a % b) + b) % b;
}
function percentFormat(fmt: string, args: any): string {
const list = Array.isArray(args) ? args : [args];
let i = 0;
return fmt.replace(/%(\([^)]*\))?[-+ 0#]*\d*(\.\d+)?([sdifr%])/g, (m, name, prec, conv) => {
if (conv === "%") return "%";
const v = name && args instanceof PyDict ? args.get(name.slice(1, -1)) : list[i++];
if (conv === "d" || conv === "i") return String(Math.trunc(Number(v)));
if (conv === "f") return Number(v).toFixed(prec ? Number(prec.slice(1)) : 6);
void m;
return pyStr(v);
});
}
export function binop(op: string, a: any, b: any): any {
switch (op) {
case "+":
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
if (typeof a === "string" || typeof b === "string") {
if (typeof a !== typeof b) throw new TypeError("str + не-str");
return a + b;
}
return a + b;
case "-":
return a - b;
case "*":
if (typeof a === "string" && typeof b === "number") return a.repeat(Math.max(0, b));
if (Array.isArray(a) && typeof b === "number") return Array.from({ length: b }, () => a).flat();
return a * b;
case "/":
return a / b;
case "//":
return Math.floor(a / b);
case "%":
if (typeof a === "string") return percentFormat(a, b);
return pyMod(a, b);
case "**":
return a ** b;
case "&":
return a & b;
case "|":
return a | b;
case "^":
return a ^ b;
case "<<":
return a << b;
case ">>":
return a >> b;
}
throw new Error(`оператор ${op}`);
}
function contains(container: any, item: any): boolean {
if (typeof container === "string") return container.includes(item);
if (Array.isArray(container)) return container.some((x) => pyEq(x, item));
if (container instanceof PyDict) return container.has(item);
if (container && typeof container === "object") return item in container;
return false;
}
function compare(op: string, a: any, b: any): boolean {
switch (op) {
case "==":
return pyEq(a, b);
case "!=":
return !pyEq(a, b);
case "<":
return a < b;
case "<=":
return a <= b;
case ">":
return a > b;
case ">=":
return a >= b;
case "in":
return contains(b, a);
case "not in":
return !contains(b, a);
case "is":
return a === b || (a == null && b == null);
case "is not":
return !(a === b || (a == null && b == null));
}
throw new Error(`сравнение ${op}`);
}
function index(len: number, i: number) {
return i < 0 ? len + i : i;
}
function methodOf(obj: any, name: string): any {
if (Array.isArray(obj)) {
const m: Record<string, Builtin> = {
append: (x) => void obj.push(x),
extend: (xs) => void obj.push(...xs),
insert: (i, x) => void obj.splice(i, 0, x),
pop: (i = -1) => obj.splice(index(obj.length, i), 1)[0],
remove: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i < 0) throw new Error("list.remove: нет элемента");
obj.splice(i, 1);
},
index: (x) => obj.findIndex((y) => pyEq(y, x)),
count: (x) => obj.filter((y) => pyEq(y, x)).length,
reverse: () => void obj.reverse(),
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
clear: () => void obj.splice(0),
copy: () => [...obj],
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
discard: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i >= 0) obj.splice(i, 1);
},
};
return m[name];
}
if (obj instanceof PyDict) {
const m: Record<string, Builtin> = {
get: (k, d = null) => (obj.has(k) ? obj.get(k) : d),
keys: () => [...obj.map.keys()],
values: () => [...obj.map.values()],
items: () => [...obj.map.entries()],
pop: (k, d = null) => {
const v = obj.has(k) ? obj.get(k) : d;
obj.map.delete(key(k));
return v;
},
setdefault: (k, d = null) => {
if (!obj.has(k)) obj.set(k, d);
return obj.get(k);
},
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()),
};
return m[name];
}
if (typeof obj === "string") {
const m: Record<string, Builtin> = {
lower: () => obj.toLowerCase(),
upper: () => obj.toUpperCase(),
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
title: () => obj.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase()),
strip: () => obj.trim(),
lstrip: () => obj.trimStart(),
rstrip: () => obj.trimEnd(),
startswith: (p) => obj.startsWith(p),
endswith: (p) => obj.endsWith(p),
replace: (a, b) => obj.split(a).join(b),
split: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
join: (xs) => xs.map(pyStr).join(obj),
format: (...args) => {
let i = 0;
return obj.replace(/\{(\d*)\}/g, (_, n) => pyStr(args[n === "" ? i++ : Number(n)]));
},
count: (s) => obj.split(s).length - 1,
find: (s) => obj.indexOf(s),
isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj),
};
return m[name];
}
return undefined;
}
export interface Scope {
get(name: string): any;
has(name: string): boolean;
set(name: string, value: any): void;
del(name: string): void;
}
export class Python {
locals: Map<string, any>[] = [];
warned = new Set<string>();
constructor(public scope: Scope) {}
warnOnce(msg: string) {
if (!this.warned.has(msg)) {
this.warned.add(msg);
console.warn("[pgvn]", msg);
}
}
lookup(name: string): any {
for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.scope.has(name)) return this.scope.get(name);
this.warnOnce(`неизвестное имя ${name}`);
return null;
}
eval(e: Expr | null | undefined): any {
if (!e) return null;
switch (e.k) {
case "const":
return e.v;
case "name":
return this.lookup(e.id);
case "attr": {
const o = this.eval(e.o);
if (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`);
return null;
}
const m = methodOf(o, e.a);
if (m) return m;
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
return v === undefined ? null : v;
}
case "sub": {
const o = this.eval(e.o);
const i = this.eval(e.i);
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null;
}
case "slice": {
const o = this.eval(e.o);
const lo = e.lo ? this.eval(e.lo) : undefined;
const hi = e.hi ? this.eval(e.hi) : undefined;
return o.slice(lo, hi);
}
case "bin":
return binop(e.op, this.eval(e.l), this.eval(e.r));
case "bool": {
let v: any = null;
for (const x of e.v) {
v = this.eval(x);
if (e.op === "and" ? !truthy(v) : truthy(v)) return v;
}
return v;
}
case "unary": {
const v = this.eval(e.v);
if (e.op === "not") return !truthy(v);
if (e.op === "-") return -v;
if (e.op === "~") return ~v;
return +v;
}
case "cmp": {
let left = this.eval(e.l);
for (let i = 0; i < e.ops.length; i++) {
const right = this.eval(e.r[i]);
if (!compare(e.ops[i], left, right)) return false;
left = right;
}
return true;
}
case "if":
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
case "list":
case "tuple":
return e.v.map((x: Expr) => this.eval(x));
case "dict":
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
case "fmt":
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
case "comp": {
const out: any[] = [];
const frame = new Map<string, any>();
this.locals.push(frame);
try {
for (const item of iterate(this.eval(e.it))) {
this.bind(e.t, item, frame);
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
}
} finally {
this.locals.pop();
}
return out;
}
case "call": {
const f = this.eval(e.f);
const args = e.a.map((x: Expr) => this.eval(x));
const kw: Record<string, any> = {};
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
if (typeof f !== "function") {
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
return null;
}
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
}
}
this.warnOnce(`неизвестное выражение ${e.k}`);
return null;
}
bind(t: Expr, value: any, frame?: Map<string, any>) {
switch (t.k) {
case "name":
if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value);
return;
case "tuple": {
const items = [...iterate(value)];
t.v.forEach((sub: Expr, i: number) => this.bind(sub, items[i], frame));
return;
}
case "attr": {
const o = this.eval(t.o);
if (o instanceof PyDict) o.set(t.a, value);
else if (o) o[t.a] = value;
return;
}
case "sub": {
const o = this.eval(t.o);
const i = this.eval(t.i);
if (o instanceof PyDict) o.set(i, value);
else if (Array.isArray(o)) o[index(o.length, i)] = value;
else if (o) o[i] = value;
return;
}
}
this.warnOnce(`присваивание в ${t.k}`);
}
exec(stmts: PyStmt[]) {
let guard = 0;
for (const s of stmts) {
switch (s.op) {
case "set":
this.bind(s.t, this.eval(s.v));
break;
case "aug":
this.bind(s.t, binop(s.o, this.eval(s.t), this.eval(s.v)));
break;
case "eval":
this.eval(s.e);
break;
case "if":
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
break;
case "while":
while (truthy(this.eval(s.c))) {
if (++guard > 100000) throw new Error("бесконечный цикл в python");
if (this.loopBody(s.body)) break;
}
break;
case "for":
for (const item of [...iterate(this.eval(s.it))]) {
this.bind(s.t, item);
if (this.loopBody(s.body)) break;
}
break;
case "del":
if (s.t.k === "name") this.scope.del(s.t.id);
else if (s.t.k === "sub") {
const o = this.eval(s.t.o);
const i = this.eval(s.t.i);
if (o instanceof PyDict) o.map.delete(key(i));
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
}
break;
case "break":
throw new BreakSignal();
case "continue":
throw new ContinueSignal();
}
}
}
/** Runs a loop body; returns true on break. */
private loopBody(body: PyStmt[]): boolean {
try {
this.exec(body);
} catch (err) {
if (err instanceof BreakSignal) return true;
if (err instanceof ContinueSignal) return false;
throw err;
}
return false;
}
}
export function* iterate(v: any): Iterable<any> {
if (Array.isArray(v) || typeof v === "string") yield* v;
else if (v instanceof PyDict) yield* v.map.keys();
else if (v && typeof v[Symbol.iterator] === "function") yield* v;
}
/** JSON (de)serialisation of the store, keeping PyDict instances. */
export function encodeValue(v: any): any {
return JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "function" ? undefined : x)));
}
export function decodeValue(v: any): any {
if (Array.isArray(v)) return v.map(decodeValue);
if (v && typeof v === "object") {
if (Array.isArray(v.__dict__))
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
const out: Record<string, any> = {};
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
return out;
}
return v;
}