Initial MVP: local visual novel library with Ren'Py → PGVN converter
- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in new issue
Block a user