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
@@ -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"],
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')"))
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}'));
|
||||
});
|
||||
Reference in new issue
Block a user