feat: add mod editing, revision merges and secure game storage

Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ import { GamePage } from "./pages/GamePage";
import { Library } from "./pages/Library";
import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame";
import { ModEditor } from "./pages/ModEditor";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { TranslationEditor } from "./pages/TranslationEditor";
@@ -112,6 +113,7 @@ export function App() {
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} />
+2
View File
@@ -20,6 +20,8 @@ export interface Me extends User {
}
export interface Version {
revision_id: string | null;
base_revision_id: string | null;
id: number;
game_id: number;
parent_id: number | null;
+16 -2
View File
@@ -85,7 +85,10 @@ export function GamePage() {
text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
});
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
if (name) void run(async () => {
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name });
navigate(`/versions/${fork.id}/edit`);
}, "Форк создан");
};
const translate = async (v: Version) => {
@@ -275,6 +278,7 @@ function VersionRow(props: {
<div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
@@ -307,7 +311,17 @@ function VersionRow(props: {
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></div>;
if (report.error) return <div className="report">
<ErrorNote>{report.error}</ErrorNote>
{report.engine && <p className="muted">Движок: {report.engine}</p>}
{report.issues?.length > 0 && <details>
<summary>Причины остановки ({report.issues.length})</summary>
<ul>{report.issues.map((issue: any, index: number) => <li key={index}>
{issue.message}{issue.count > 1 ? ` · ${issue.count}×` : ""}
{issue.location && <span className="muted"> — {issue.location}</span>}
</li>)}</ul>
</details>}
</div>;
const stat = (value: string | number, label: string) => (
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
);
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
import { ErrorNote, Spinner } from "../ui/Bits";
import { useUi } from "../ui/Ui";
import "./mods.css";
type Obj = Record<string, any>;
interface Document { game: Obj; scripts: Record<string, { file: string; labels: Obj[] }> }
interface Editor { revision: string; baseRevision: string | null; document: Document;
files: Record<string, { hash: string; size: number }>; editable: boolean; changes: string[];
diff: { path: string; before: unknown; after: unknown }[] }
interface Conflict { path: string; base: unknown; left: unknown; right: unknown; leftDeleted: boolean; rightDeleted: boolean }
interface Unmatched { id: string; module: string; label: string; text: string; candidates: { id: string; text: string }[] }
interface Preview { conflicts: Conflict[]; unmatched: Unmatched[]; baseRevision: string }
function uid() {
return globalThis.crypto?.randomUUID?.() ?? "mod-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
function makeNode(op: string): Obj {
const id = uid();
const fields: Record<string, Obj> = {
say: { who: null, what: "Новая реплика", tid: "mod_" + id },
jump: { target: "start" }, call: { target: "start" },
scene: { img: null, at: [] }, show: { img: { name: ["bg", "room"] }, at: [] },
hide: { tag: "bg" }, pause: { d: { k: "const", v: 1 } },
if: { branches: [{ id: uid(), c: { k: "const", v: true }, body: [] }] },
while: { c: { k: "const", v: false }, body: [] },
menu: { items: [{ id: uid(), text: "Вариант", if: null, body: [] }] },
exec: { code: [] }, play: { ch: "music", files: [{ k: "const", v: "audio/music.ogg" }], loop: true },
stop: { ch: "music" }, window: { mode: "show" },
};
return { id, op, ...fields[op] };
}
function JsonField({ value, onChange, label }: { value: any; onChange: (v: any) => void; label: string }) {
const [text, setText] = useState(JSON.stringify(value ?? null, null, 2));
const [error, setError] = useState("");
useEffect(() => setText(JSON.stringify(value ?? null, null, 2)), [value]);
return <label className="mod-field">{label}<textarea value={text} rows={Math.min(12, text.split("\n").length + 1)}
aria-invalid={!!error} onChange={(e) => {
setText(e.target.value);
try { const v = JSON.parse(e.target.value); setError(""); e.target.setCustomValidity(""); onChange(v); }
catch { setError("Некорректный JSON"); e.target.setCustomValidity("Некорректный JSON"); }
}} />{error && <span role="alert">{error}</span>}</label>;
}
function Commands({ nodes, onChange, versionId }: { nodes: Obj[]; onChange: (n: Obj[]) => void; versionId: string }) {
const ui = useUi();
const [op, setOp] = useState("say");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const patch = (i: number, node: Obj) => onChange(nodes.map((n, k) => k === i ? node : n));
const move = (i: number, delta: number) => {
const next = [...nodes]; [next[i], next[i + delta]] = [next[i + delta], next[i]]; onChange(next);
};
return <div className="mod-commands">
{nodes.map((n, i) => <details className="mod-command" key={n.id} open={expanded.has(n.id)} onToggle={(e) => {
const open = e.currentTarget.open;
setExpanded((previous) => {
if (previous.has(n.id) === open) return previous;
const next = new Set(previous); if (open) next.add(n.id); else next.delete(n.id); return next;
});
}}>
<summary>{i + 1}. {n.op} {n.what ?? n.target ?? n.name ?? ""}</summary>
{expanded.has(n.id) && <>
<div className="actions">
<button type="button" className="btn btn-sm" disabled={i === 0} onClick={() => move(i, -1)}>Выше</button>
<button type="button" className="btn btn-sm" disabled={i === nodes.length - 1} onClick={() => move(i, 1)}>Ниже</button>
<button type="button" className="btn btn-sm" onClick={() => { const next = [...nodes]; next.splice(i + 1, 0, makeNode(op)); onChange(next); }}>Вставить после</button>
<button type="button" className="btn btn-sm btn-danger" onClick={async () => {
if (await ui.confirm({ title: "Удалить команду вместе с вложенными ветками?", danger: true })) onChange(nodes.filter((_, k) => i !== k));
}}>Удалить</button>
<Link className="btn btn-sm" target="_blank" to={`/play/${versionId}?at=${encodeURIComponent(n.id)}`}>Играть отсюда после сохранения</Link>
</div>
{n.op === "say" && <>
<label className="mod-field">Персонаж<input value={n.who ?? ""} onChange={(e) => patch(i, { ...n, who: e.target.value || null })} /></label>
<label className="mod-field">Реплика<textarea value={n.what} onChange={(e) => patch(i, { ...n, what: e.target.value })} /></label>
</>}
{typeof n.target === "string" && <label className="mod-field">Метка перехода<input value={n.target} onChange={(e) => patch(i, { ...n, target: e.target.value })} /></label>}
{(n.op === "scene" || n.op === "show") && <label className="mod-field">Имя изображения<input value={n.img?.name?.join(" ") ?? ""}
onChange={(e) => patch(i, { ...n, img: e.target.value ? { name: e.target.value.split(/\s+/) } : null })} /></label>}
{n.op === "while" && <JsonField label="Условие (PGVN)" value={n.c} onChange={(c) => patch(i, { ...n, c })} />}
{n.op === "while" && <Commands nodes={n.body} versionId={versionId} onChange={(body) => patch(i, { ...n, body })} />}
{(n.op === "menu" || n.op === "if") && (() => {
const key = n.op === "menu" ? "items" : "branches";
const condKey = n.op === "menu" ? "if" : "c";
return <div>{n[key].map((b: Obj, j: number) => <section className="mod-branch" key={b.id}>
{n.op === "menu" && <label className="mod-field">Вариант выбора<input value={b.text} onChange={(e) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, text: e.target.value } : x) })} /></label>}
<JsonField label="Условие (null — без условия)" value={b[condKey]} onChange={(c) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, [condKey]: c } : x) })} />
<Commands nodes={b.body} versionId={versionId} onChange={(body) => patch(i, { ...n, [key]: n[key].map((x: Obj, k: number) => k === j ? { ...b, body } : x) })} />
<button className="btn btn-sm btn-danger" type="button" onClick={async () => {
if (await ui.confirm({ title: "Удалить ветку?", danger: true })) patch(i, { ...n, [key]: n[key].filter((_: Obj, k: number) => k !== j) });
}}>Удалить ветку</button>
</section>)}<button className="btn btn-sm" type="button" onClick={() => patch(i, { ...n, [key]: [...n[key], { id: uid(), [condKey]: null, body: [], ...(n.op === "menu" ? { text: "Вариант" } : {}) }] })}>Добавить ветку</button></div>;
})()}
<details><summary>Другие параметры команды (PGVN)</summary><JsonField label="Параметры" value={Object.fromEntries(Object.entries(n).filter(([k]) => !["id", "tid", "op", "body", "items", "branches"].includes(k)))}
onChange={(fields) => { if (fields && !Array.isArray(fields) && typeof fields === "object") {
const keep = Object.fromEntries(Object.entries(n).filter(([k]) => ["id", "tid", "op", "body", "items", "branches"].includes(k)));
patch(i, { ...fields, ...keep });
} }} /></details></>}
</details>)}
<div className="actions"><select aria-label="Тип новой команды" value={op} onChange={(e) => setOp(e.target.value)}>
{["say", "scene", "show", "hide", "jump", "call", "return", "pause", "menu", "if", "while", "exec", "play", "stop", "window", "noop"].map((v) => <option key={v}>{v}</option>)}
</select><button className="btn btn-sm" type="button" onClick={() => onChange([...nodes, makeNode(op)])}>Добавить команду</button></div>
</div>;
}
export function ModEditor() {
const { versionId = "" } = useParams();
const navigate = useNavigate();
const ui = useUi();
const { can } = useAuth();
const [data, setData] = useState<Editor | null>(null);
const [version, setVersion] = useState<Version | null>(null);
const [versions, setVersions] = useState<Version[]>([]);
const [history, setHistory] = useState<{ id: string; created_at: string }[]>([]);
const [module, setModule] = useState("");
const [label, setLabel] = useState(0);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [target, setTarget] = useState("");
const [mode, setMode] = useState("merge");
const [preview, setPreview] = useState<Preview | null>(null);
const [choices, setChoices] = useState<Record<string, string>>({});
const [mapping, setMapping] = useState<Record<string, string>>({});
const load = async () => {
const [next, v, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<Version>(`/api/versions/${versionId}`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
const game = await api.get<GameDetail>(`/api/games/${v.game_id}`);
setData(next); setVersion(v); setHistory(revisions); setVersions(game.versions.filter((x) => x.id !== v.id && x.status === "ready"));
setModule((old) => next.document.scripts[old] ? old : Object.keys(next.document.scripts).find((p) => next.document.scripts[p].labels.length) ?? next.document.game.scripts[0]); setLabel(0); setDirty(false); setPreview(null);
setChoices({}); setMapping({}); setTarget(""); setError("");
};
useEffect(() => { setData(null); void load().catch((e) => setError(e.message)); }, [versionId]);
useEffect(() => {
const guard = (e: BeforeUnloadEvent) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } };
window.addEventListener("beforeunload", guard); return () => window.removeEventListener("beforeunload", guard);
}, [dirty]);
const run = async (fn: () => Promise<void>) => {
setBusy(true); setError(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); }
};
if (!data || !version) return <div className="page">{error ? <ErrorNote>{error}</ErrorNote> : <Spinner />}</div>;
const change = (document: Document) => { setData({ ...data, document }); setDirty(true); setPreview(null); };
const mod = data.document.scripts[module];
const selected = mod?.labels[label];
const setLabelValue = (value: Obj) => change({ ...data.document, scripts: { ...data.document.scripts,
[module]: { ...mod, labels: mod.labels.map((l, i) => i === label ? value : l) } } });
const merge = async (apply: boolean) => {
const t = versions.find((v) => v.id === Number(target));
if (!t) throw new Error("Выберите целевую версию");
const body = { target_id: t.id, source_revision: data.revision, target_revision: t.revision_id, mode, choices, mapping, apply,
name: `${version.name} + ${t.name}`.slice(0, 255) };
if (apply) {
const result = await api.post<{ id: number }>(`/api/versions/${versionId}/merge`, body);
navigate(`/versions/${result.id}/edit`);
} else setPreview(await api.post<Preview>(`/api/versions/${versionId}/merge`, body));
};
return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1>
<Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link>
<Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link>
</div>{error && <ErrorNote>{error}</ErrorNote>}
<p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p>
{!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>}
<form onSubmit={(e) => { e.preventDefault(); void run(async () => {
const result = await api.put<{ revision: string; files: Editor["files"] }>(`/api/versions/${versionId}/editor`, { revision: data.revision, document: data.document });
setData({ ...data, revision: result.revision, files: result.files }); setDirty(false); ui.toast("Мод сохранён", "ok");
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
setData(fresh); setHistory(revisions);
}); }}><fieldset disabled={busy || !data.editable}>
<div className="mod-toolbar"><label>Модуль<select value={module} onChange={(e) => { setModule(e.target.value); setLabel(0); }}>{Object.keys(data.document.scripts).map((p) => <option key={p}>{p}</option>)}</select></label>
<label>Метка<select value={label} onChange={(e) => setLabel(Number(e.target.value))}>{mod?.labels.map((l, i) => <option key={l.id} value={i}>{l.name}</option>)}</select></label>
<button className="btn" type="button" onClick={async () => {
const name = await ui.prompt({ title: "Новая метка", label: "Имя" });
if (name) { change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: [...mod.labels, { id: uid(), name, body: [] }] } } }); setLabel(mod.labels.length); }
}}>Добавить метку</button>
</div>
{selected && <><label className="mod-field">Имя метки<input value={selected.name} onChange={(e) => setLabelValue({ ...selected, name: e.target.value })} /></label>
<Commands nodes={selected.body} versionId={versionId} onChange={(body) => setLabelValue({ ...selected, body })} />
<button className="btn btn-danger" type="button" onClick={async () => {
if (await ui.confirm({ title: "Удалить метку со всеми командами?", danger: true })) {
change({ ...data.document, scripts: { ...data.document.scripts, [module]: { ...mod, labels: mod.labels.filter((_, i) => i !== label) } } }); setLabel(0);
}
}}>Удалить метку</button></>}
<details><summary>Настройки игры (PGVN)</summary><JsonField label="game.json" value={data.document.game} onChange={(game) => { if (game && typeof game === "object" && !Array.isArray(game)) change({ ...data.document, game }); }} /></details>
<button className="btn btn-primary" disabled={!dirty} type="submit">{busy ? "Сохранение…" : "Сохранить мод"}</button>
</fieldset></form>
<details><summary>Отличия от базы ({data.changes?.length ?? 0} файлов)</summary><ul>{data.changes?.map((p) => <li key={p}>{p}</li>)}</ul>
{data.diff.map((d, i) => <details key={i}><summary>{d.path}</summary><div className="mod-conflict-values"><div>Было<pre>{JSON.stringify(d.before, null, 2)}</pre></div><div>Стало<pre>{JSON.stringify(d.after, null, 2)}</pre></div></div></details>)}
<button className="btn btn-sm" disabled={dirty || busy} onClick={() => void run(load)}>Обновить отличия</button></details>
<details><summary>Замена ассетов</summary><p>Заменяется файл только в этом моде. Сначала сохраните скрипт.</p>
<div className="mod-assets">{Object.entries(data.files).filter(([p]) => !p.startsWith("script/") && !p.startsWith("locales/") && p !== "game.json").map(([path, f]) => <div key={path}>
<a href={`/api/versions/${versionId}/files/${f.hash}`} target="_blank" rel="noreferrer">{path}</a>
<input aria-label={`Заменить ${path}`} type="file" disabled={busy || dirty || !data.editable} onChange={(e) => {
const file = e.target.files?.[0]; e.target.value = "";
if (file) void run(async () => {
const result = await uploadRaw<{ revision: string; files: Editor["files"] }>("PUT", `/api/versions/${versionId}/asset?${new URLSearchParams({ path, revision: data.revision, filename: file.name })}`, file, () => {});
setData({ ...data, ...result }); ui.toast("Ассет заменён", "ok");
const [fresh, revisions] = await Promise.all([api.get<Editor>(`/api/versions/${versionId}/editor`), api.get<typeof history>(`/api/versions/${versionId}/revisions`)]);
setData(fresh); setHistory(revisions);
});
}} /></div>)}</div>
</details>
<details><summary>История и откат</summary>{history.map((h) => <div className="actions" key={h.id}><span>{new Date(h.created_at).toLocaleString()} · {h.id.slice(0, 8)}</span>
<button className="btn btn-sm" disabled={busy || dirty || !data.editable || h.id === data.revision} onClick={() => void run(async () => {
if (await ui.confirm({ title: "Восстановить эту ревизию?", text: "Текущее состояние останется в истории." })) { await api.post(`/api/versions/${versionId}/revisions/restore`, { revision: data.revision, restore: h.id }); await load(); }
})}>Восстановить</button></div>)}</details>
{can("versions.fork") && version.kind === "mod" && <section><h2>Слияние и перенос</h2><p>Результат создаётся отдельным черновиком. Исходные версии сохраняются.</p>
<div className="mod-toolbar"><select aria-label="Операция" value={mode} onChange={(e) => { setMode(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="merge">Объединить моды</option><option value="rebase">Перенести на новый оригинал</option></select>
<select aria-label="Целевая версия" value={target} onChange={(e) => { setTarget(e.target.value); setPreview(null); setChoices({}); setMapping({}); }}><option value="">Выберите версию</option>{versions.map((v) => <option value={v.id} key={v.id}>{v.name}</option>)}</select>
<button className="btn" disabled={busy || dirty || !target} onClick={() => void run(() => merge(false))}>Проверить совместимость</button></div>
{preview && <div><p>База {preview.baseRevision.slice(0, 8)}. Конфликтов: {preview.conflicts.length}.</p>
{preview.unmatched.length > 0 && <details open><summary>Не сопоставленные узлы ({preview.unmatched.length})</summary><p>Выберите соответствие для изменённых команд и повторите проверку. Без соответствия команда считается удалённой в новом оригинале; изменённые модом команды потребуют разрешения конфликта.</p>
{preview.unmatched.map((n) => <label className="mod-field" key={n.id}>{n.module} / {n.label}: {n.text}<select value={mapping[n.id] ?? ""} onChange={(e) => { const next = { ...mapping }; if (e.target.value) next[n.id] = e.target.value; else delete next[n.id]; setMapping(next); setChoices({}); }}><option value="">Нет соответствия</option>{n.candidates.map((c) => <option value={c.id} key={c.id}>{c.text} · {c.id.slice(0, 8)}</option>)}</select></label>)}</details>}
{preview.conflicts.map((c) => <section className="mod-conflict" key={c.path}><b>{c.path}</b><div className="mod-conflict-values">{(["base", "left", "right"] as const).map((side) => <div key={side}><p>{side === "base" ? "База" : side === "left" ? "Этот мод" : "Целевая версия"}</p><pre>{JSON.stringify(c[side], null, 2)}</pre></div>)}</div>
<select aria-label={`Решение ${c.path}`} value={choices[c.path] ?? ""} onChange={(e) => setChoices({ ...choices, [c.path]: e.target.value })}><option value="">Выберите решение</option><option value="left">Этот мод{c.leftDeleted ? " (удалить)" : ""}</option><option value="right">Целевая версия{c.rightDeleted ? " (удалить)" : ""}</option></select></section>)}
<button className="btn" disabled={busy || dirty} onClick={() => void run(() => merge(false))}>Повторить проверку с решениями</button>
<button className="btn btn-primary" disabled={busy || dirty || preview.conflicts.some((c) => !choices[c.path])} onClick={() => void run(() => merge(true))}>Создать черновик результата</button>
</div>}
</section>}
</div>;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function NewGame() {
return (
<div className="page medium">
<PageHeader title="Новая игра"
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
subtitle="Загрузите архив игры. Сервер определит движок и проверит сценарии. Неподдерживаемые игры будут отклонены с объяснением причины." />
<UploadProgress />
<form className="card form" onSubmit={submit}>
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
+18
View File
@@ -0,0 +1,18 @@
.mod-editor fieldset { border: 0; padding: 0; min-width: 0; }
.mod-editor > .section-title { flex-wrap: wrap; }
.mod-editor > .section-title h1 { flex: 1 1 100%; min-width: 0; overflow-wrap: anywhere; }
.mod-editor > .section-title .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h); }
.mod-editor details { margin: 1rem 0; }
.mod-editor summary { cursor: pointer; overflow-wrap: anywhere; }
.mod-editor select, .mod-editor input, .mod-editor textarea { max-width: 100%; }
.mod-field { display: grid; gap: .4rem; margin: .8rem 0; }
.mod-field textarea { width: 100%; min-height: 4rem; font: inherit; }
.mod-toolbar { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; margin: 1rem 0; }
.mod-toolbar label { display: grid; gap: .4rem; }
.mod-command, .mod-conflict { border: 1px solid var(--border); padding: .8rem; border-radius: .4rem; }
.mod-command > summary { padding-bottom: .5rem; }
.mod-branch { border-left: 2px solid var(--border); padding-left: .8rem; margin: 1rem 0; }
.mod-assets > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .6rem; padding: .6rem 0; }
.mod-conflict-values { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; }
.mod-conflict pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 20rem; overflow: auto; }
@media (max-width: 700px) { .mod-conflict-values { grid-template-columns: 1fr; } }
+76 -5
View File
@@ -5,6 +5,7 @@ import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, ty
import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types";
@@ -138,8 +139,8 @@ export class Engine {
}
this.persistent = this.makePersistent();
const scope: Scope = {
get: (n) => (n in this.store ? this.store[n] : this.builtins[n]),
has: (n) => n in this.store || n in this.builtins,
get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]),
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n),
set: (n, v) => void (this.store[n] = v),
del: (n) => void delete this.store[n],
};
@@ -189,7 +190,7 @@ export class Engine {
}
}
const entry = logical ? this.manifest.files[logical] : undefined;
return entry ? `/blobs/${entry.hash}` : null;
return entry ? `/api/versions/${this.manifest.versionId}/files/${entry.hash}` : null;
}
private resolveImage(name: string[]): ResolvedImage | null {
@@ -262,6 +263,7 @@ export class Engine {
return this.py.eval(e);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка выражения", err);
return null;
}
@@ -614,6 +616,48 @@ export class Engine {
private async exec(node: Node) {
switch (node.op) {
case "nani_exit":
this.stack = [];
this.cur = { label: "", path: [0] };
return;
case "nani_add_choice":
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
target: node.target, handler: node.handler });
this.advance();
return;
case "nani_clear_choice":
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
this.advance();
return;
case "nani_menu": {
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
this.interactionStart();
this.setSkip(false);
const target = await this.wait<string>((done) => {
this.menuEl.textContent = "";
for (const choice of choices) {
const button = el("button", "pg-choice");
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
this.menuEl.append(button);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
this.store.__nani_choices = [];
this.jump(target);
return;
}
case "nani_hide_characters":
this.renderer.commit();
for (const image of this.renderer.snapshot()) {
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
}
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
this.advance();
return;
case "say":
await this.say(node);
this.advance();
@@ -693,6 +737,7 @@ export class Engine {
this.py.exec(node.code);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка python", err, node.code);
}
this.advance();
@@ -771,6 +816,8 @@ export class Engine {
}
private interp(s: string): string {
if (this.game.engine === "naninovel") return naniText(s, this.game.naniExpressions ?? {},
(name) => Object.hasOwn(this.store, name) ? this.store[name] : undefined);
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
}
@@ -1066,10 +1113,22 @@ export class Engine {
}
private snapshotState(): SaveData {
const anchor = (f: Frame): Frame => {
const label = this.labels.get(f.label)?.label;
let body = label?.body;
for (let i = 0; body && i < f.path.length - 1; i += 2) {
const n = body[f.path[i]];
if (!n) { body = undefined; break; }
body = n.op === "if" ? n.branches[f.path[i + 1]]?.body
: n.op === "menu" ? n.items[f.path[i + 1]]?.body : n.body;
}
return { ...f, path: [...f.path], nodeId: body?.[f.path[f.path.length - 1]]?.id };
};
return {
v: 1,
cur: { label: this.cur.label, path: [...this.cur.path] },
stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })),
revision: this.manifest.revision,
cur: anchor(this.cur),
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
store: encodeValue(this.store),
scene: this.renderer.snapshot(),
music: this.audio.current("music"),
@@ -1101,6 +1160,18 @@ export class Engine {
this.notify("Сохранение не подходит к этой версии игры");
return;
}
if (data.revision && data.revision !== this.manifest.revision) {
// IDs repair positions, but cannot prove compatibility of variables/game logic.
const frames = [data.cur, ...data.stack];
const mapped = frames.map((f) => f.nodeId ? this.findNode(f.nodeId) : null);
if (mapped.some((f) => !f)) {
this.notify("После изменения игры позиция сохранения не найдена. Используйте прежнюю ревизию или начните заново");
return;
}
if (!(await this.confirmAsk("Игра изменилась. Позиции найдены по ID, но состояние переменных может быть несовместимо. Попробовать загрузить?", "Загрузить"))) return;
frames.forEach((f, i) => Object.assign(f, mapped[i]));
}
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
this.restart(() => {
this.resetStore();
Object.assign(this.store, decodeValue(data.store));
+1 -1
View File
@@ -74,7 +74,7 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
const blob = (path: string) => {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return fetch(`/blobs/${f.hash}`).then((r) => {
return fetch(`/api/versions/${manifest.versionId}/files/${f.hash}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${path}`);
return r.json() as Promise<Record<string, Entry>>;
});
+1 -1
View File
@@ -12,7 +12,7 @@ async function getJSON<T>(url: string): Promise<T> {
function blob(manifest: Manifest, path: string) {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return `/blobs/${f.hash}`;
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
}
function saveApi(versionId: number): SaveApi {
+88
View File
@@ -0,0 +1,88 @@
// Evaluate the server-parsed Naninovel AST. Game text is never evaluated as JS.
export type NaniAst = any[];
export function naniTruth(value: any): boolean {
if (typeof value === "string") return value !== "" && !["false", "0", "null"].includes(value.toLowerCase());
return Boolean(value);
}
function number(value: any): number {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error(`Naninovel: не число ${String(value)}`);
return n;
}
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): any {
const evaluate = (node: NaniAst): any => {
switch (node[0]) {
case "const": return node[1];
case "name": return lookup(node[1]) ?? 0;
case "unary": {
const value = evaluate(node[2]);
if (node[1] === "!" || node[1] === "not") return !naniTruth(value);
if (node[1] === "-") return -number(value);
if (node[1] === "+") return number(value);
if (node[1] === "~") return ~number(value);
throw new Error(`Naninovel: оператор ${node[1]}`);
}
case "if": return evaluate(naniTruth(evaluate(node[1])) ? node[2] : node[3]);
case "bin": {
const a = evaluate(node[2]);
if (["and", "&&"].includes(node[1])) return naniTruth(a) && naniTruth(evaluate(node[3]));
if (["or", "||"].includes(node[1])) return naniTruth(a) || naniTruth(evaluate(node[3]));
const b = evaluate(node[3]);
switch (node[1]) {
case "+": return typeof a === "string" || typeof b === "string" ? String(a) + String(b) : number(a) + number(b);
case "-": return number(a) - number(b);
case "*": return number(a) * number(b);
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
case "=": case "==": return String(a) === String(b);
case "!=": case "<>": return String(a) !== String(b);
case "<": return number(a) < number(b);
case ">": return number(a) > number(b);
case "<=": return number(a) <= number(b);
case ">=": return number(a) >= number(b);
case "&": return number(a) & number(b);
case "|": return number(a) | number(b);
case "^": return number(a) ^ number(b);
case "<<": return number(a) << number(b);
case ">>": return number(a) >> number(b);
default: throw new Error(`Naninovel: оператор ${node[1]}`);
}
}
case "call": {
const args = node[2].map((value: NaniAst) => number(evaluate(value)));
switch (node[1]) {
case "Random": case "random": case "Rand": {
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
return min + Math.floor(random() * (max - min));
}
case "GetRandValue": return Math.floor(random() * args[0]);
case "Min": return Math.min(...args);
case "Max": return Math.max(...args);
case "Abs": return Math.abs(args[0]);
case "Pow": return Math.pow(args[0], args[1]);
case "Round": { const power = 10 ** (args[1] ?? 0); return Math.round(args[0] * power) / power; }
default: throw new Error(`Naninovel: функция ${node[1]}`);
}
}
default: throw new Error(`Naninovel: узел выражения ${ast[0]}`);
}
};
return evaluate(ast);
}
export function naniText(source: string, expressions: Record<string, NaniAst>, lookup: (name: string) => any): string {
// Expressions are substituted after the locale is selected. Brackets in their
// results are escaped before passing to the shared rich-text renderer.
const escaped = source.replace(/\{([^{}]+)\}/g, (_match, expression: string) => {
const ast = expressions[expression];
if (!ast) throw new Error(`Naninovel: неизвестное выражение в тексте ${expression}`);
return String(evaluateNani(ast, lookup)).replaceAll("{", "{{").replaceAll("[", "[[");
});
return escaped.replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}")
.replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}")
.replace(/<\/(color|size)>/gi, "{/$1}")
.replace(/<br\s*\/?>/gi, "\n");
}
+19 -3
View File
@@ -2,6 +2,7 @@
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
import type { Expr, PyStmt } from "./types";
import { evaluateNani } from "./naninovel";
export class PyDict {
map = new Map<any, any>();
@@ -157,9 +158,16 @@ function compare(op: string, a: any, b: any): boolean {
}
function index(len: number, i: number) {
if (!Number.isInteger(i)) throw new Error("Индекс последовательности должен быть целым числом");
return i < 0 ? len + i : i;
}
function safeProperty(name: any): boolean {
if (typeof name !== "string") return typeof name === "number" && Number.isFinite(name);
return !name.startsWith("__") &&
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
}
function methodOf(obj: any, name: string): any {
if (Array.isArray(obj)) {
const m: Record<string, Builtin> = {
@@ -184,7 +192,7 @@ function methodOf(obj: any, name: string): any {
if (i >= 0) obj.splice(i, 1);
},
};
return m[name];
return Object.hasOwn(m, name) ? m[name] : undefined;
}
if (obj instanceof PyDict) {
const m: Record<string, Builtin> = {
@@ -205,7 +213,7 @@ function methodOf(obj: any, name: string): any {
clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()),
};
return m[name];
return Object.hasOwn(m, name) ? m[name] : undefined;
}
if (typeof obj === "string") {
const m: Record<string, Builtin> = {
@@ -230,7 +238,7 @@ function methodOf(obj: any, name: string): any {
isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj),
};
return m[name];
return Object.hasOwn(m, name) ? m[name] : undefined;
}
return undefined;
}
@@ -256,6 +264,7 @@ export class Python {
}
lookup(name: string): any {
if (!safeProperty(name)) return null;
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);
@@ -266,12 +275,15 @@ export class Python {
eval(e: Expr | null | undefined): any {
if (!e) return null;
switch (e.k) {
case "nani":
return evaluateNani(e.ast, (name) => safeProperty(name) && this.scope.has(name) ? this.scope.get(name) : undefined);
case "const":
return e.v;
case "name":
return this.lookup(e.id);
case "attr": {
const o = this.eval(e.o);
if (!safeProperty(e.a) || typeof o === "function") return null;
if (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`);
return null;
@@ -285,6 +297,7 @@ export class Python {
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 (!safeProperty(i) || typeof o === "function") return null;
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null;
}
@@ -362,6 +375,7 @@ export class Python {
bind(t: Expr, value: any, frame?: Map<string, any>) {
switch (t.k) {
case "name":
if (!safeProperty(t.id)) throw new Error("Служебное имя недоступно");
if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value);
return;
@@ -372,6 +386,7 @@ export class Python {
}
case "attr": {
const o = this.eval(t.o);
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
if (o instanceof PyDict) o.set(t.a, value);
else if (o) o[t.a] = value;
return;
@@ -380,6 +395,7 @@ export class Python {
const o = this.eval(t.o);
const i = this.eval(t.i);
if (o instanceof PyDict) o.set(i, value);
else if (!safeProperty(i) || typeof o === "function") throw new Error("Служебный ключ недоступен");
else if (Array.isArray(o)) o[index(o.length, i)] = value;
else if (o) o[i] = value;
return;
+6
View File
@@ -92,6 +92,8 @@ export interface ScriptModule {
}
export interface GameData {
engine?: string;
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
format: string;
title: string | null;
resolution: [number, number];
@@ -112,11 +114,14 @@ export interface GameData {
}
export interface Manifest {
versionId?: number;
revision?: string;
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
nodeId?: string;
label: string;
path: number[];
// values of label parameters shadowed by a call, restored on return
@@ -132,6 +137,7 @@ export interface ShownImage {
}
export interface SaveData {
revision?: string;
v: 1;
cur: Frame;
stack: Frame[];