Server-side save states via js-dos fsChanges hooks
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
artworkUrl,
|
||||
bundleUrl,
|
||||
setupBundleUrl,
|
||||
pullSaves,
|
||||
pushSaves,
|
||||
deleteSaves,
|
||||
} from "../api.js";
|
||||
|
||||
describe("artworkUrl", () => {
|
||||
it("returns null for null input", () => {
|
||||
@@ -66,3 +73,113 @@ describe("setupBundleUrl", () => {
|
||||
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullSaves", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns null when there is no save (404)", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ status: 404, ok: false }));
|
||||
await expect(pullSaves("game-1")).resolves.toBeNull();
|
||||
expect(fetch).toHaveBeenCalledWith("/api/games/game-1/saves");
|
||||
});
|
||||
|
||||
it("returns the raw bytes on success", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(bytes.buffer),
|
||||
})
|
||||
);
|
||||
await expect(pullSaves("game-1")).resolves.toEqual(bytes);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
status: 500,
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "boom" }),
|
||||
})
|
||||
);
|
||||
await expect(pullSaves("game-1")).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushSaves", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("PUTs the raw bundle to the saves endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: "saved" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const data = new Uint8Array([9, 8, 7]);
|
||||
|
||||
await expect(pushSaves("game-1", data)).resolves.toEqual({
|
||||
status: "saved",
|
||||
});
|
||||
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/games/game-1/saves");
|
||||
expect(opts.method).toBe("PUT");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/octet-stream");
|
||||
expect(opts.body).toBe(data);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
status: 400,
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Empty save data" }),
|
||||
})
|
||||
);
|
||||
await expect(pushSaves("game-1", new Uint8Array())).rejects.toThrow(
|
||||
"Empty save data"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSaves", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("DELETEs the saves endpoint", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: "deleted" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(deleteSaves("game-1")).resolves.toEqual({ status: "deleted" });
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
"/api/games/game-1/saves",
|
||||
{ method: "DELETE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on server error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
status: 500,
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "boom" }),
|
||||
})
|
||||
);
|
||||
await expect(deleteSaves("game-1")).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,41 @@ export function downloadGame(id) {
|
||||
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
||||
}
|
||||
|
||||
/** Pull saved js-dos FS changes (in-game saves) — Uint8Array or null if none */
|
||||
export async function pullSaves(gameId) {
|
||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/** Push js-dos FS changes (in-game saves) to the server */
|
||||
export async function pushSaves(gameId, data) {
|
||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: data,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Delete server-side saves for a game */
|
||||
export async function deleteSaves(gameId) {
|
||||
const res = await fetch(`${BASE}/api/games/${gameId}/saves`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Check IGDB status */
|
||||
export async function igdbStatus() {
|
||||
const res = await fetch(`${BASE}/api/igdb/status`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { getGame, bundleUrl, setupBundleUrl } from "../lib/api.js";
|
||||
import { getGame, bundleUrl, setupBundleUrl, pullSaves, pushSaves, deleteSaves } from "../lib/api.js";
|
||||
import { push } from "../lib/router.js";
|
||||
|
||||
let { id } = $props();
|
||||
@@ -70,7 +70,26 @@
|
||||
try {
|
||||
const url = isSetup ? setupBundleUrl(game) : bundleUrl(game);
|
||||
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
||||
dosCI = await window.Dos(dosContainer, { url });
|
||||
dosCI = window.Dos(dosContainer, {
|
||||
url,
|
||||
autoSave: true,
|
||||
// Server-backed FS persistence: in-game saves survive sessions and
|
||||
// follow the player across browsers/devices. Both normal and setup
|
||||
// mode share one save set so SETUP.EXE changes carry over.
|
||||
fsChanges: {
|
||||
urlToKey: async () => String(game.id),
|
||||
pull: async (key) => {
|
||||
try {
|
||||
return await pullSaves(key);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load saves:", e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
push: (key, data) => pushSaves(key, data),
|
||||
delete: (key) => deleteSaves(key),
|
||||
},
|
||||
});
|
||||
running = true;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
@@ -78,7 +97,12 @@
|
||||
booting = false;
|
||||
}
|
||||
|
||||
function stopEmulator() {
|
||||
async function stopEmulator() {
|
||||
// Persist FS changes before teardown — the reload below kills the
|
||||
// emulator worker, so the save request must fire first (best effort).
|
||||
if (dosCI?.save) {
|
||||
try { await dosCI.save(); } catch (_) {}
|
||||
}
|
||||
// Force full page reload which terminates all workers, AudioContexts
|
||||
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
||||
window.location.hash = `#/game/${game.id}`;
|
||||
@@ -107,8 +131,12 @@
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (dosCI) {
|
||||
try { dosCI.exit(); } catch (_) {}
|
||||
// Persist FS changes, then kill the emulation (DosProps.stop()).
|
||||
const props = dosCI;
|
||||
dosCI = null;
|
||||
Promise.resolve(props.save?.()).catch(() => {}).finally(() => {
|
||||
try { props.stop?.(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
if (dosContainer) {
|
||||
dosContainer.innerHTML = "";
|
||||
@@ -171,7 +199,7 @@
|
||||
<p class="save-hint">⚙️ Configure controls, then exit SETUP to save</p>
|
||||
{:else}
|
||||
<p>Starting emulator...</p>
|
||||
<p class="save-hint">💾 Game saves are stored automatically</p>
|
||||
<p class="save-hint">💾 Game saves are stored on the server</p>
|
||||
{/if}
|
||||
{:else if error}
|
||||
<div class="overlay-icon">⚠️</div>
|
||||
|
||||
@@ -7,6 +7,9 @@ vi.mock("../../lib/api.js", () => ({
|
||||
getGame: vi.fn(),
|
||||
bundleUrl: vi.fn(),
|
||||
setupBundleUrl: vi.fn(),
|
||||
pullSaves: vi.fn(),
|
||||
pushSaves: vi.fn(),
|
||||
deleteSaves: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock router
|
||||
@@ -74,4 +77,49 @@ describe("Play", () => {
|
||||
expect(screen.getByText("Starting emulator...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts js-dos with server-backed save hooks", async () => {
|
||||
api.getGame.mockResolvedValue(makeGame());
|
||||
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
|
||||
|
||||
const dosMock = vi.fn().mockReturnValue({
|
||||
save: vi.fn().mockResolvedValue(true),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
window.Dos = dosMock;
|
||||
|
||||
try {
|
||||
render(Play, { props: { id: "game-1" } });
|
||||
await vi.waitFor(() => {
|
||||
expect(dosMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const opts = dosMock.mock.calls[0][1];
|
||||
expect(opts.url).toBe("/games/game-1.jsdos");
|
||||
expect(opts.autoSave).toBe(true);
|
||||
|
||||
// Both normal and setup bundles map to the same per-game save key
|
||||
await expect(opts.fsChanges.urlToKey("/games/game-1.jsdos")).resolves.toBe("game-1");
|
||||
|
||||
// pull() forwards to the API and returns null when there is no save
|
||||
api.pullSaves.mockResolvedValue(null);
|
||||
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
|
||||
expect(api.pullSaves).toHaveBeenCalledWith("game-1");
|
||||
|
||||
// push()/delete() forward to the API
|
||||
api.pushSaves.mockResolvedValue({ status: "saved" });
|
||||
await opts.fsChanges.push("game-1", new Uint8Array([1]));
|
||||
expect(api.pushSaves).toHaveBeenCalledWith("game-1", expect.any(Uint8Array));
|
||||
|
||||
api.deleteSaves.mockResolvedValue({ status: "deleted" });
|
||||
await opts.fsChanges.delete("game-1");
|
||||
expect(api.deleteSaves).toHaveBeenCalledWith("game-1");
|
||||
|
||||
// pull() survives API errors so the game still boots
|
||||
api.pullSaves.mockRejectedValue(new Error("offline"));
|
||||
await expect(opts.fsChanges.pull("game-1")).resolves.toBeNull();
|
||||
} finally {
|
||||
delete window.Dos;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user