feat: local browser saves with opt-in cloud sync behind saves.cloud permission
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4418da47eb
commit
47fc121e7d
8 files changed
+377
-31
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user