feat: local browser saves with opt-in cloud sync behind saves.cloud permission
CI / frontend (push) Successful in 21s
CI / backend (push) Successful in 47s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:44:42 +03:00
1 parent 4418da47eb
commit 47fc121e7d
8 files changed
+377 -31

No files matched your search

+3
View File
@@ -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() {
<div className="muted small">{me.role.name}</div>
</div>
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
<NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" size={18} />Сохранения</NavLink>
{(can("users.manage") || can("roles.manage")) && <div className="menu-section">Администрирование</div>}
{can("users.manage") && (
<NavLink to="/admin/users" className="menu-item" role="menuitem"><Icon name="users" size={18} />Пользователи</NavLink>
@@ -117,6 +119,7 @@ export function App() {
<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 />} />
<Route path="/saves" element={<Saves />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
+71
View File
@@ -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<HTMLInputElement>(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 (
<div className="page">
<PageHeader title="Сохранения" subtitle="По умолчанию хранятся только в этом браузере" />
<section className="card">
<h2>Облако</h2>
<p className="muted">
Облачные сохранения копируются на сервер и доступны с других устройств.
Включаются отдельно на каждом устройстве; если сервер недоступен, игра сохраняется локально.
</p>
{allowed ? (
<label className="row">
<input type="checkbox" checked={cloud} onChange={(e) => toggle(e.target.checked)} />
Синхронизировать сохранения с сервером
</label>
) : (
<p className="muted">У вашей роли нет права «Облачные сохранения» — обратитесь к администратору.</p>
)}
</section>
<section className="card">
<h2>Перенос файлом</h2>
<p className="muted">Скачайте все локальные сохранения или загрузите файл с другого устройства.</p>
<div className="actions">
<button className="btn" onClick={() => void download()}><Icon name="download" size={18} />Скачать</button>
<button className="btn" onClick={() => file.current?.click()}><Icon name="upload" size={18} />Загрузить</button>
<input ref={file} type="file" accept="application/json,.json" hidden
onChange={(e) => void upload(e.target.files?.[0])} />
</div>
</section>
</div>
);
}
+6 -24
View File
@@ -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<T>(url: string): Promise<T> {
@@ -16,28 +17,8 @@ function blob(manifest: Manifest, path: string) {
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
}
function saveApi<T = SaveData>(versionId: number): SaveApi<T> {
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<T = SaveData>(versionId: number, cloud: boolean): SaveApi<T> {
return makeSaveApi<T>(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<ScriptModule>(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");
+195
View File
@@ -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<T = unknown> 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<string, StoredSave & { key: string }>();
let opened: Promise<IDBDatabase | null> | null = null;
function open(): Promise<IDBDatabase | null> {
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<R>(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest<R>): Promise<R> {
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<StoredSave[]> {
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<StoredSave | null> {
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<void> {
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<void> {
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<boolean> {
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<T>(versionId: number, cloud: boolean): SaveApi<T> {
const base = `/api/versions/${versionId}/saves`;
const cloudList = async (): Promise<SaveMeta[]> => {
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<string | null> => {
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<StoredSave<T> | 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<string, SaveMeta>();
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<T> | 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<T> = { 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<string> {
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<number> {
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;
}
+74
View File
@@ -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}'));
});