diff --git a/README.md b/README.md index 6d4c38f..b4cbdf0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and met ### 🎮 Play in the browser Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs. -**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off. +**Saves are automatic** — in-game saves (js-dos FS changes) are persisted to the server (`data/saves/`). Come back from any browser or device and pick up where you left off. ### 📦 Handles any file structure DOS games come in all shapes. DOStalgia handles them transparently: @@ -131,4 +131,4 @@ java -jar target/quarkus-app/quarkus-run.jar - **Frontend**: Svelte 5 SPA with hash-based routing - **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly - **Storage**: JSON-per-game under `/data/games/{id}/game.json` -- **Saves**: Browser localStorage / indexedDB (managed by js-dos) \ No newline at end of file +- **Saves**: `data/saves/` on the server (js-dos FS-changes bundles, synced via `/api/games/{id}/saves`) \ No newline at end of file diff --git a/frontend/src/lib/__tests__/api.test.js b/frontend/src/lib/__tests__/api.test.js index aea4cb6..dce1c7b 100644 --- a/frontend/src/lib/__tests__/api.test.js +++ b/frontend/src/lib/__tests__/api.test.js @@ -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"); + }); +}); diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 34a1c41..ead65c9 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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`); diff --git a/frontend/src/pages/Play.svelte b/frontend/src/pages/Play.svelte index c3a578a..8a33088 100644 --- a/frontend/src/pages/Play.svelte +++ b/frontend/src/pages/Play.svelte @@ -1,5 +1,5 @@