diff --git a/backend/app/permissions.py b/backend/app/permissions.py
index 8e3bf3f..419b95f 100644
--- a/backend/app/permissions.py
+++ b/backend/app/permissions.py
@@ -9,6 +9,7 @@ PERMISSIONS: dict[str, str] = {
"versions.manage_any": "Управление чужими версиями",
"translations.edit": "Перевод игр в своих версиях",
"versions.publish": "Публикация версий и переводов, выбор главной версии",
+ "saves.cloud": "Облачные сохранения (синхронизация между устройствами)",
"users.manage": "Управление пользователями",
"roles.manage": "Управление ролями",
}
@@ -18,6 +19,6 @@ ALL = sorted(PERMISSIONS)
SYSTEM_ROLES: dict[str, list[str]] = {
"admin": ALL,
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork",
- "translations.edit", "versions.publish"],
- "player": ["games.play"],
+ "translations.edit", "versions.publish", "saves.cloud"],
+ "player": ["games.play", "saves.cloud"],
}
diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py
index 445ea6a..eceaeff 100644
--- a/backend/app/routers/versions.py
+++ b/backend/app/routers/versions.py
@@ -268,7 +268,9 @@ async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
remove_upload(version_id)
-# --- saves -----------------------------------------------------------------
+# --- cloud saves -----------------------------------------------------------
+# Saves live in the browser by default; these endpoints are the opt-in cloud copy.
+CloudSaves = Annotated[User, Depends(require("games.play", "saves.cloud"))]
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):
@@ -277,7 +279,7 @@ def _check_slot(slot: str) -> str:
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
-async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
+async def list_saves(version_id: int, user: CloudSaves, 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)
@@ -291,7 +293,7 @@ async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | N
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
-async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
+async def get_save(version_id: int, slot: str, user: CloudSaves, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot)
if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
@@ -299,7 +301,7 @@ async def get_save(version_id: int, slot: str, user: Player, db: DB) -> 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:
+async def put_save(version_id: int, slot: str, body: SaveIn, user: CloudSaves, db: DB) -> Save:
await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot)
if save is None:
@@ -315,7 +317,7 @@ async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: D
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
-async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
+async def delete_save(version_id: int, slot: str, user: CloudSaves, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
diff --git a/backend/migrations/versions/0004_cloud_saves_permission.py b/backend/migrations/versions/0004_cloud_saves_permission.py
new file mode 100644
index 0000000..e73dcd5
--- /dev/null
+++ b/backend/migrations/versions/0004_cloud_saves_permission.py
@@ -0,0 +1,18 @@
+"""Cloud saves became an explicit permission; keep them for existing system roles."""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0004"
+down_revision = "0003"
+branch_labels = depends_on = None
+
+
+def upgrade():
+ op.get_bind().execute(sa.text(
+ "UPDATE roles SET permissions = array_append(permissions, 'saves.cloud') "
+ "WHERE is_system AND name IN ('moderator', 'player') AND NOT ('saves.cloud' = ANY(permissions))"))
+
+
+def downgrade():
+ op.get_bind().execute(sa.text(
+ "UPDATE roles SET permissions = array_remove(permissions, 'saves.cloud')"))
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3d0475f..bffb714 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import { NewGame } from "./pages/NewGame";
import { ModEditor } from "./pages/ModEditor";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
+import { Saves } from "./pages/Saves";
import { TranslationEditor } from "./pages/TranslationEditor";
import { Users } from "./pages/Users";
import { useTheme } from "./theme";
@@ -60,6 +61,7 @@ function UserMenu() {
{me.role.name}
Аккаунт и пароль
+ Сохранения
{(can("users.manage") || can("roles.manage")) && Администрирование
}
{can("users.manage") && (
Пользователи
@@ -117,6 +119,7 @@ export function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/pages/Saves.tsx b/frontend/src/pages/Saves.tsx
new file mode 100644
index 0000000..e232016
--- /dev/null
+++ b/frontend/src/pages/Saves.tsx
@@ -0,0 +1,71 @@
+import { useRef, useState } from "react";
+import { useAuth } from "../auth";
+import { cloudEnabled, exportSaves, importSaves, setCloudEnabled } from "../player/saves";
+import { PageHeader } from "../ui/Bits";
+import { Icon } from "../ui/Icon";
+import { useUi } from "../ui/Ui";
+
+export function Saves() {
+ const { can } = useAuth();
+ const ui = useUi();
+ const allowed = can("saves.cloud");
+ const [cloud, setCloud] = useState(cloudEnabled());
+ const file = useRef(null);
+
+ const toggle = (on: boolean) => {
+ setCloudEnabled(on);
+ setCloud(on);
+ ui.toast(on ? "Облачные сохранения включены на этом устройстве" : "Облачные сохранения выключены", "ok");
+ };
+
+ const download = async () => {
+ const url = URL.createObjectURL(new Blob([await exportSaves()], { type: "application/json" }));
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "pgames-saves.json";
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const upload = async (f: File | undefined) => {
+ if (!f) return;
+ try {
+ const n = await importSaves(await f.text());
+ ui.toast(n ? `Загружено сохранений: ${n}` : "Новых сохранений в файле нет", "ok");
+ } catch (e) {
+ ui.toast((e as Error).message, "error");
+ }
+ if (file.current) file.current.value = "";
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/player/index.ts b/frontend/src/player/index.ts
index 991d3da..3d15404 100644
--- a/frontend/src/player/index.ts
+++ b/frontend/src/player/index.ts
@@ -2,6 +2,7 @@ import "./player.css";
import { Engine, type SaveApi } from "./engine";
import { loadTranslator, preferredLanguage } from "./i18n";
import { RUNTIMES } from "./runtimes";
+import { cloudActive, makeSaveApi } from "./saves";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON(url: string): Promise {
@@ -16,28 +17,8 @@ function blob(manifest: Manifest, path: string) {
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
}
-function saveApi(versionId: number): SaveApi {
- const base = `/api/versions/${versionId}/saves`;
- return {
- list: () => getJSON(base),
- async get(slot) {
- const r = await fetch(`${base}/${slot}`);
- if (r.status === 404) return null;
- if (!r.ok) throw new Error(`${r.status}`);
- return (await r.json()).data as T;
- },
- async put(slot, title, data) {
- const r = await fetch(`${base}/${slot}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title, data }),
- });
- if (!r.ok) throw new Error(`${r.status}`);
- },
- async remove(slot) {
- await fetch(`${base}/${slot}`, { method: "DELETE" });
- },
- };
+function saveApi(versionId: number, cloud: boolean): SaveApi {
+ return makeSaveApi(versionId, cloud);
}
export interface PlayerHandle { destroy(): void }
@@ -56,12 +37,13 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
Promise.all(game.scripts.map((s) => getJSON(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
]);
+ const cloud = await cloudActive();
if (game.runtime) {
const load = RUNTIMES[game.runtime];
if (!load) throw new Error(`Неизвестная среда исполнения игры: ${game.runtime}`);
- return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi });
+ return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi: (id) => saveApi(id, cloud) });
}
- const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
+ const engine = new Engine(root, game, manifest, modules, saveApi(versionId, cloud),
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
const at = url.get("at");
diff --git a/frontend/src/player/saves.ts b/frontend/src/player/saves.ts
new file mode 100644
index 0000000..0efc499
--- /dev/null
+++ b/frontend/src/player/saves.ts
@@ -0,0 +1,195 @@
+// Saves are local to every browser (IndexedDB). The server copy is an opt-in
+// extra: it is used only when the user switched it on here and has `saves.cloud`.
+import type { SaveApi, SaveMeta } from "./engine-util";
+
+export interface StoredSave extends SaveMeta {
+ versionId: number;
+ data: T;
+}
+
+const DB_NAME = "pgvn-saves";
+const STORE = "saves";
+const CLOUD_KEY = "pgvn:cloud-saves";
+
+export function cloudEnabled(): boolean {
+ try { return localStorage.getItem(CLOUD_KEY) === "1"; } catch { return false; }
+}
+
+export function setCloudEnabled(on: boolean) {
+ try { localStorage.setItem(CLOUD_KEY, on ? "1" : "0"); } catch { /* storage unavailable */ }
+}
+
+const memory = new Map();
+let opened: Promise | null = null;
+
+function open(): Promise {
+ opened ??= new Promise((resolve) => {
+ try {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "key" });
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(null);
+ } catch {
+ resolve(null); // private mode etc.: fall back to memory
+ }
+ });
+ return opened;
+}
+
+function request(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ const req = run(db.transaction(STORE, mode).objectStore(STORE));
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+const keyOf = (versionId: number, slot: string) => `${versionId}:${slot}`;
+
+export async function localAll(): Promise {
+ const db = await open();
+ if (!db) return [...memory.values()];
+ return (await request(db, "readonly", (s) => s.getAll())) as StoredSave[];
+}
+
+export async function localGet(versionId: number, slot: string): Promise {
+ const db = await open();
+ if (!db) return memory.get(keyOf(versionId, slot)) ?? null;
+ return ((await request(db, "readonly", (s) => s.get(keyOf(versionId, slot)))) as StoredSave | undefined) ?? null;
+}
+
+export async function localPut(save: StoredSave): Promise {
+ const row = { ...save, key: keyOf(save.versionId, save.slot) };
+ const db = await open();
+ if (!db) memory.set(row.key, row);
+ else await request(db, "readwrite", (s) => s.put(row));
+}
+
+export async function localRemove(versionId: number, slot: string): Promise {
+ const db = await open();
+ if (!db) memory.delete(keyOf(versionId, slot));
+ else await request(db, "readwrite", (s) => s.delete(keyOf(versionId, slot)));
+}
+
+const time = (iso: string) => Date.parse(iso) || 0;
+
+/** Cloud availability: switched on in this browser and allowed for the account. */
+export async function cloudActive(): Promise {
+ if (!cloudEnabled()) return false;
+ try {
+ const r = await fetch("/api/auth/me", { credentials: "same-origin" });
+ return r.ok && ((await r.json()).permissions as string[]).includes("saves.cloud");
+ } catch {
+ return false;
+ }
+}
+
+export function makeSaveApi(versionId: number, cloud: boolean): SaveApi {
+ const base = `/api/versions/${versionId}/saves`;
+ const cloudList = async (): Promise => {
+ if (!cloud) return [];
+ try {
+ const r = await fetch(base, { credentials: "same-origin" });
+ return r.ok ? await r.json() : [];
+ } catch {
+ return []; // offline: local saves keep working
+ }
+ };
+ const cloudPut = async (slot: string, title: string, data: unknown): Promise => {
+ try {
+ const r = await fetch(`${base}/${slot}`, {
+ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, data }),
+ });
+ return r.ok ? (await r.json()).updated_at : null;
+ } catch {
+ return null;
+ }
+ };
+ const cloudGet = async (slot: string): Promise | null> => {
+ try {
+ const r = await fetch(`${base}/${slot}`, { credentials: "same-origin" });
+ if (!r.ok) return null;
+ const body = await r.json();
+ return { versionId, slot, title: body.title, updated_at: body.updated_at, data: body.data };
+ } catch {
+ return null;
+ }
+ };
+ return {
+ async list() {
+ const [local, remote] = await Promise.all([
+ localAll().then((all) => all.filter((s) => s.versionId === versionId)), cloudList(),
+ ]);
+ const best = new Map();
+ for (const s of [...local, ...remote]) {
+ const prev = best.get(s.slot);
+ if (!prev || time(s.updated_at) > time(prev.updated_at)) {
+ best.set(s.slot, { slot: s.slot, title: s.title, updated_at: s.updated_at });
+ }
+ }
+ // local copies the cloud lacks or has older are pushed in the background
+ if (cloud) {
+ const theirs = new Map(remote.map((s) => [s.slot, time(s.updated_at)]));
+ for (const s of local) {
+ if (time(s.updated_at) > (theirs.get(s.slot) ?? -1)) {
+ void cloudPut(s.slot, s.title, s.data).then((at) => { if (at) void localPut({ ...s, updated_at: at }); });
+ }
+ }
+ }
+ return [...best.values()].sort((a, b) => time(b.updated_at) - time(a.updated_at));
+ },
+ async get(slot) {
+ const local = (await localGet(versionId, slot)) as StoredSave | null;
+ if (!cloud) return local?.data ?? null;
+ const remote = await cloudGet(slot);
+ if (remote && (!local || time(remote.updated_at) > time(local.updated_at))) {
+ await localPut(remote);
+ return remote.data;
+ }
+ return local?.data ?? null;
+ },
+ async put(slot, title, data) {
+ const row: StoredSave = { versionId, slot, title, data, updated_at: new Date().toISOString() };
+ await localPut(row);
+ if (cloud) {
+ const at = await cloudPut(slot, title, data);
+ if (at) await localPut({ ...row, updated_at: at });
+ }
+ },
+ async remove(slot) {
+ await localRemove(versionId, slot);
+ if (cloud) await fetch(`${base}/${slot}`, { method: "DELETE" }).catch(() => {});
+ },
+ };
+}
+
+// --- file export / import (moving saves between browsers without the cloud) ---
+
+const FILE_TAG = "pgvn-saves";
+
+export async function exportSaves(): Promise {
+ const saves = (await localAll()).map(({ versionId, slot, title, updated_at, data }) => ({
+ versionId, slot, title, updated_at, data,
+ }));
+ return JSON.stringify({ format: FILE_TAG, version: 1, saves });
+}
+
+/** Returns how many saves were imported; an existing newer local save wins. */
+export async function importSaves(text: string): Promise {
+ const file = JSON.parse(text);
+ if (file?.format !== FILE_TAG || !Array.isArray(file.saves)) throw new Error("Это не файл сохранений PGames");
+ let count = 0;
+ for (const s of file.saves) {
+ const valid = Number.isInteger(s?.versionId) && typeof s.slot === "string" && /^[\w-]{1,32}$/.test(s.slot)
+ && typeof s.data === "object" && s.data !== null;
+ if (!valid) continue;
+ const have = await localGet(s.versionId, s.slot);
+ if (have && time(have.updated_at) >= time(String(s.updated_at))) continue;
+ await localPut({
+ versionId: s.versionId, slot: s.slot, title: String(s.title ?? "").slice(0, 255),
+ updated_at: String(s.updated_at), data: s.data,
+ });
+ count++;
+ }
+ return count;
+}
diff --git a/frontend/tests/saves.test.mjs b/frontend/tests/saves.test.mjs
new file mode 100644
index 0000000..ce28d77
--- /dev/null
+++ b/frontend/tests/saves.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { beforeEach, test } from "node:test";
+import ts from "typescript";
+
+// No indexedDB in node: the module falls back to its in-memory store, which is what we exercise.
+const source = readFileSync(new URL("../src/player/saves.ts", import.meta.url), "utf8");
+const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
+const saves = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
+
+let remote, calls;
+beforeEach(async () => {
+ for (const s of await saves.localAll()) await saves.localRemove(s.versionId, s.slot);
+ remote = new Map();
+ calls = [];
+ globalThis.fetch = async (url, init = {}) => {
+ const method = init.method ?? "GET";
+ calls.push(`${method} ${url}`);
+ const m = /saves\/(\w+)$/.exec(url);
+ const json = (body, ok = true) => ({ ok, json: async () => body });
+ if (!m) return json([...remote.entries()].map(([slot, s]) => ({ slot, title: s.title, updated_at: s.updated_at })));
+ if (method === "PUT") {
+ const body = JSON.parse(init.body);
+ const row = { ...body, updated_at: "2030-01-01T00:00:00Z" };
+ remote.set(m[1], row);
+ return json(row);
+ }
+ return remote.has(m[1]) ? json(remote.get(m[1])) : json({}, false);
+ };
+});
+
+test("without the cloud nothing touches the network", async () => {
+ const api = saves.makeSaveApi(1, false);
+ await api.put("a", "t", { x: 1 });
+ assert.deepEqual(await api.get("a"), { x: 1 });
+ assert.equal((await api.list()).length, 1);
+ assert.deepEqual(calls, []);
+ await api.remove("a");
+ assert.equal(await api.get("a"), null);
+});
+
+test("saves are separated by game version", async () => {
+ await saves.makeSaveApi(1, false).put("a", "one", { v: 1 });
+ await saves.makeSaveApi(2, false).put("a", "two", { v: 2 });
+ assert.deepEqual(await saves.makeSaveApi(1, false).get("a"), { v: 1 });
+ assert.equal((await saves.makeSaveApi(2, false).list())[0].title, "two");
+});
+
+test("cloud put keeps a local copy and survives a server outage", async () => {
+ const api = saves.makeSaveApi(1, true);
+ await api.put("a", "t", { x: 1 });
+ assert.deepEqual(remote.get("a").data, { x: 1 });
+ globalThis.fetch = async () => { throw new Error("offline"); };
+ await api.put("b", "t", { y: 2 });
+ assert.deepEqual(await api.get("b"), { y: 2 });
+ assert.equal((await api.list()).length, 2);
+});
+
+test("the newer copy wins when loading", async () => {
+ remote.set("a", { title: "cloud", data: { from: "cloud" }, updated_at: "2031-01-01T00:00:00Z" });
+ await saves.localPut({ versionId: 1, slot: "a", title: "local", data: { from: "local" }, updated_at: "2020-01-01T00:00:00Z" });
+ assert.deepEqual(await saves.makeSaveApi(1, true).get("a"), { from: "cloud" });
+ assert.equal((await saves.localGet(1, "a")).title, "cloud");
+});
+
+test("export and import round-trip and reject foreign files", async () => {
+ await saves.makeSaveApi(3, false).put("quick", "q", { z: 3 });
+ const file = await saves.exportSaves();
+ await saves.localRemove(3, "quick");
+ assert.equal(await saves.importSaves(file), 1);
+ assert.deepEqual(await saves.makeSaveApi(3, false).get("quick"), { z: 3 });
+ assert.equal(await saves.importSaves(file), 0);
+ await assert.rejects(saves.importSaves('{"a":1}'));
+});