From 0980b789d5741f99af89e26a96155a649eb2a7b9 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 02:17:31 -0700 Subject: [PATCH 1/3] feat(saves): store game saves on the player's account Adds game_save_snapshots and /api/game-saves for copies of the saves games keep in a player's browser. An upload matching the current save is a no-op, the newest 10 snapshots per user and game are kept as backups, and saves may be up to 10 MB, so the router parses its own bodies past the app's 1 MB JSON limit. --- backend/src/migrations/008_game_saves.sql | 21 ++++ backend/src/routes/game-saves.test.ts | 121 ++++++++++++++++++++++ backend/src/routes/game-saves.ts | 74 +++++++++++++ backend/src/routes/index.ts | 2 + backend/src/server.ts | 6 +- backend/src/services/GameSaveService.ts | 102 ++++++++++++++++++ 6 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 backend/src/migrations/008_game_saves.sql create mode 100644 backend/src/routes/game-saves.test.ts create mode 100644 backend/src/routes/game-saves.ts create mode 100644 backend/src/services/GameSaveService.ts diff --git a/backend/src/migrations/008_game_saves.sql b/backend/src/migrations/008_game_saves.sql new file mode 100644 index 0000000..477e137 --- /dev/null +++ b/backend/src/migrations/008_game_saves.sql @@ -0,0 +1,21 @@ +-- =================================== +-- Migration 008: Game saves +-- =================================== +-- Copies of the saves games keep in a player's browser, so a fresh browser can +-- restore them. Each row is one snapshot; the newest few per user and game are +-- kept as backups. + +CREATE TABLE IF NOT EXISTS game_save_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + game_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + data TEXT NOT NULL, + size INTEGER NOT NULL, + saved_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_game_save_snapshots_user_game + ON game_save_snapshots(user_id, game_id, id DESC); diff --git a/backend/src/routes/game-saves.test.ts b/backend/src/routes/game-saves.test.ts new file mode 100644 index 0000000..854b776 --- /dev/null +++ b/backend/src/routes/game-saves.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import express, { Express } from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import fs from 'fs'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +// UserDatabaseService creates the directory +const tempDir = vi.hoisted( + () => `${process.env.TMPDIR ?? '/tmp'}/game-saves-${process.pid}-${Date.now()}` +); + +vi.mock('../config', async (importOriginal) => { + const actual = await importOriginal(); + return { config: { ...actual.config, userDbPath: `${tempDir}/user.db` } }; +}); + +import gameSavesRouter from './game-saves'; +import { errorHandler } from '../middleware/errorHandler'; +import { UserDatabaseService } from '../services/UserDatabaseService'; +import { AuthService } from '../services/AuthService'; + +const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df'; + +describe('game saves API', () => { + let app: Express; + let alice: string; + let bob: string; + + beforeAll(async () => { + await UserDatabaseService.initialize(); + const authService = new AuthService(); + alice = (await authService.trustedLogin('alice', null)).tokens.accessToken; + bob = (await authService.trustedLogin('bobby', null)).tokens.accessToken; + app = express(); + app.use(cookieParser()); + app.use('/api/game-saves', gameSavesRouter); + app.use(errorHandler); + }); + + afterAll(() => { + UserDatabaseService.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + const save = (token: string, hash: string, data: object = { localStorage: { k: hash } }) => + request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${token}`) + .send({ data, hash, savedAt: '2026-10-12T10:00:00.000Z' }); + + const fetchSaves = (token: string) => + request(app).get(`/api/game-saves/${GAME_ID}`).set('Authorization', `Bearer ${token}`); + + it('starts empty and requires a signed-in user', async () => { + expect((await request(app).get(`/api/game-saves/${GAME_ID}`)).status).toBe(401); + expect((await fetchSaves(alice)).body).toEqual({ latest: null, snapshots: [] }); + }); + + it('stores saves, skipping one identical to the current save', async () => { + const first = await save(alice, 'h1'); + expect(first.status).toBe(201); + expect((await save(alice, 'h1')).status).toBe(200); + expect((await save(alice, 'h2')).status).toBe(201); + + const { body } = await fetchSaves(alice); + expect(body.latest).toMatchObject({ hash: 'h2', data: { localStorage: { k: 'h2' } } }); + expect(body.snapshots.map((s: { hash: string }) => s.hash)).toEqual(['h2', 'h1']); + + const older = await request(app) + .get(`/api/game-saves/${GAME_ID}/${first.body.id}`) + .set('Authorization', `Bearer ${alice}`); + expect(older.body).toMatchObject({ hash: 'h1', data: { localStorage: { k: 'h1' } } }); + }); + + it('keeps only the newest 10 saves', async () => { + for (let i = 3; i <= 14; i++) { + await save(alice, `h${i}`); + } + const { body } = await fetchSaves(alice); + expect(body.snapshots).toHaveLength(10); + expect(body.snapshots[0].hash).toBe('h14'); + expect(body.snapshots[9].hash).toBe('h5'); + }); + + it("keeps each user's saves separate", async () => { + expect((await fetchSaves(bob)).body.latest).toBeNull(); + const aliceSnapshot = (await fetchSaves(alice)).body.latest.id; + const res = await request(app) + .get(`/api/game-saves/${GAME_ID}/${aliceSnapshot}`) + .set('Authorization', `Bearer ${bob}`); + expect(res.status).toBe(404); + }); + + it('accepts saves over the app-wide 1 MB body limit, and clamps future timestamps', async () => { + const big = { indexedDB: 'x'.repeat(2 * 1024 * 1024) }; + const res = await request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${bob}`) + .send({ data: big, hash: 'big', savedAt: '2999-01-01T00:00:00.000Z' }); + expect(res.status).toBe(201); + expect(res.body.size).toBeGreaterThan(2 * 1024 * 1024); + expect(Date.parse(res.body.savedAt)).toBeLessThanOrEqual(Date.now()); + }); + + it('rejects invalid input', async () => { + const bad = await request(app) + .post('/api/game-saves/not-a-game') + .set('Authorization', `Bearer ${alice}`) + .send({ data: {}, hash: 'x', savedAt: '2026-10-12T10:00:00.000Z' }); + expect(bad.status).toBe(400); + const noHash = await request(app) + .post(`/api/game-saves/${GAME_ID}`) + .set('Authorization', `Bearer ${alice}`) + .send({ data: {}, savedAt: '2026-10-12T10:00:00.000Z' }); + expect(noHash.status).toBe(400); + }); +}); diff --git a/backend/src/routes/game-saves.ts b/backend/src/routes/game-saves.ts new file mode 100644 index 0000000..dfa7b97 --- /dev/null +++ b/backend/src/routes/game-saves.ts @@ -0,0 +1,74 @@ +import express, { Router } from 'express'; +import { z } from 'zod'; +import { authenticate } from '../middleware/auth'; +import { requirePermission } from '../middleware/rbac'; +import { AppError } from '../middleware/errorHandler'; +import { asyncHandler } from '../middleware/asyncHandler'; +import { GameSaveService } from '../services/GameSaveService'; + +const router = Router(); +const gameSaveService = new GameSaveService(); + +/** Saves can outgrow the app's 1 MB JSON limit; server.ts leaves parsing to this router. */ +export const GAME_SAVE_BODY_LIMIT = '10mb'; + +const gameIdSchema = z.string().uuid('Invalid game ID format'); + +const saveSchema = z.object({ + data: z.record(z.unknown()), + hash: z.string().min(1).max(64), + savedAt: z.string().datetime(), +}); + +router.use(express.json({ limit: GAME_SAVE_BODY_LIMIT })); +router.use(authenticate, requirePermission('games.play')); + +function userId(req: express.Request): number { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } + return req.user.id; +} + +// The current save with its data, and every kept snapshot's metadata +router.get( + '/:gameId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const id = userId(req); + res.json({ + latest: gameSaveService.get(id, gameId), + snapshots: gameSaveService.list(id, gameId), + }); + }) +); + +router.get( + '/:gameId/:snapshotId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const snapshotId = parseInt(req.params.snapshotId, 10); + if (isNaN(snapshotId)) { + throw new AppError(400, 'Invalid snapshot ID'); + } + const snapshot = gameSaveService.get(userId(req), gameId, snapshotId); + if (!snapshot) { + throw new AppError(404, 'Save not found'); + } + res.json(snapshot); + }) +); + +router.post( + '/:gameId', + asyncHandler(async (req, res) => { + const gameId = gameIdSchema.parse(req.params.gameId); + const body = saveSchema.parse(req.body); + // A browser's clock can run ahead; a save can't be from the future + const savedAt = new Date(Math.min(Date.parse(body.savedAt), Date.now())).toISOString(); + const result = gameSaveService.save(userId(req), gameId, body.data, body.hash, savedAt); + res.status(result.created ? 201 : 200).json(result.snapshot); + }) +); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 2423f73..1abd0bd 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -22,6 +22,7 @@ import userPlaylistsRouter from './user-playlists'; import sharedPlaylistsRouter from './shared-playlists'; import favoritesRouter from './favorites'; import ratingsRouter from './ratings'; +import gameSavesRouter from './game-saves'; import ruffleRouter from './ruffle'; import githubRouter from './github'; import domainsRouter from './domains'; @@ -49,6 +50,7 @@ export function setupRoutes(app: Express): void { app.use('/api/playlists/shared', sharedPlaylistsRouter); app.use('/api/favorites', favoritesRouter); app.use('/api/ratings', ratingsRouter); + app.use('/api/game-saves', gameSavesRouter); app.use('/api/games', gamesRouter); app.use('/api/games', downloadsRouter); app.use('/api/platforms', platformsRouter); diff --git a/backend/src/server.ts b/backend/src/server.ts index 55bdfd9..244be37 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -209,7 +209,11 @@ async function startServer() { }) ); - app.use(express.json({ limit: '1mb' })); + // Game saves parse their own, larger bodies (routes/game-saves.ts) + const jsonParser = express.json({ limit: '1mb' }); + app.use((req, res, next) => + req.path.startsWith('/api/game-saves') ? next() : jsonParser(req, res, next) + ); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(cookieParser()); diff --git a/backend/src/services/GameSaveService.ts b/backend/src/services/GameSaveService.ts new file mode 100644 index 0000000..b7334f7 --- /dev/null +++ b/backend/src/services/GameSaveService.ts @@ -0,0 +1,102 @@ +import { UserDatabaseService } from './UserDatabaseService'; + +/** Snapshots kept per user and game, the newest being the current save. */ +export const SNAPSHOTS_KEPT = 10; + +export interface GameSaveSnapshotMeta { + id: number; + hash: string; + size: number; + savedAt: string; + createdAt: string; +} + +export interface GameSaveSnapshot extends GameSaveSnapshotMeta { + data: unknown; +} + +interface SnapshotRow { + id: number; + content_hash: string; + size: number; + saved_at: string; + created_at: string; + data?: string; +} + +function toMeta(row: SnapshotRow): GameSaveSnapshotMeta { + return { + id: row.id, + hash: row.content_hash, + size: row.size, + savedAt: row.saved_at, + createdAt: row.created_at, + }; +} + +/** Stores copies of the saves games keep in a player's browser. */ +export class GameSaveService { + /** All snapshots for a game, newest first. */ + list(userId: number, gameId: string): GameSaveSnapshotMeta[] { + return UserDatabaseService.all( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ORDER BY id DESC`, + [userId, gameId] + ).map(toMeta); + } + + get(userId: number, gameId: string, id?: number): GameSaveSnapshot | null { + const row = UserDatabaseService.get( + `SELECT id, content_hash, size, saved_at, created_at, data FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ${id === undefined ? '' : 'AND id = ?'} + ORDER BY id DESC LIMIT 1`, + id === undefined ? [userId, gameId] : [userId, gameId, id] + ); + return row ? { ...toMeta(row), data: JSON.parse(row.data ?? 'null') } : null; + } + + /** + * Stores a snapshot as the game's current save, unless it matches the current + * one, then drops snapshots beyond the newest SNAPSHOTS_KEPT. + */ + save( + userId: number, + gameId: string, + data: unknown, + hash: string, + savedAt: string + ): { snapshot: GameSaveSnapshotMeta; created: boolean } { + const json = JSON.stringify(data); + const db = UserDatabaseService.getDatabase(); + return db.transaction(() => { + const latest = db + .prepare( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots + WHERE user_id = ? AND game_id = ? ORDER BY id DESC LIMIT 1` + ) + .get(userId, gameId) as SnapshotRow | undefined; + if (latest?.content_hash === hash) { + return { snapshot: toMeta(latest), created: false }; + } + + const result = db + .prepare( + `INSERT INTO game_save_snapshots (user_id, game_id, content_hash, data, size, saved_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run(userId, gameId, hash, json, Buffer.byteLength(json), savedAt); + db.prepare( + `DELETE FROM game_save_snapshots WHERE user_id = ? AND game_id = ? AND id NOT IN ( + SELECT id FROM game_save_snapshots WHERE user_id = ? AND game_id = ? + ORDER BY id DESC LIMIT ?)` + ).run(userId, gameId, userId, gameId, SNAPSHOTS_KEPT); + + const row = db + .prepare( + `SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots WHERE id = ?` + ) + .get(result.lastInsertRowid) as SnapshotRow; + return { snapshot: toMeta(row), created: true }; + })(); + } +} From 1e098674d8a50f3a678c0b223ce7e42d8494e4c2 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 02:33:21 -0700 Subject: [PATCH 2/3] feat(saves): keep players' game saves on their account Saves games keep in the browser now sync with the signed-in player's account: Ruffle SharedObjects and HTML5 localStorage, IndexedDB databases (exported and imported whole, typed arrays and blobs included), and streamed games' save files. Games share the app's origin, so storage that changes while a game runs is attributed to it. Before a game starts, the account's save is loaded unless this browser has a newer one; changes upload as they happen; and a Saves dialog in the player restores any of the last 10, keeping the save it replaces as a backup. --- frontend/package.json | 1 + frontend/src/components/player/GamePlayer.tsx | 241 +++++++++------- .../src/components/player/SavesDialog.tsx | 114 ++++++++ frontend/src/hooks/useGameSaveSync.test.tsx | 131 +++++++++ frontend/src/hooks/useGameSaveSync.ts | 177 ++++++++++++ frontend/src/lib/api/gameSaves.ts | 62 ++++ frontend/src/lib/api/index.ts | 1 + frontend/src/lib/saves/gameSaves.test.ts | 86 ++++++ frontend/src/lib/saves/gameSaves.ts | 222 ++++++++++++++ frontend/src/lib/saves/hash.test.ts | 10 + frontend/src/lib/saves/hash.ts | 27 ++ frontend/src/lib/saves/indexedDb.test.ts | 88 ++++++ frontend/src/lib/saves/indexedDb.ts | 271 ++++++++++++++++++ package-lock.json | 37 +++ 14 files changed, 1369 insertions(+), 99 deletions(-) create mode 100644 frontend/src/components/player/SavesDialog.tsx create mode 100644 frontend/src/hooks/useGameSaveSync.test.tsx create mode 100644 frontend/src/hooks/useGameSaveSync.ts create mode 100644 frontend/src/lib/api/gameSaves.ts create mode 100644 frontend/src/lib/saves/gameSaves.test.ts create mode 100644 frontend/src/lib/saves/gameSaves.ts create mode 100644 frontend/src/lib/saves/hash.test.ts create mode 100644 frontend/src/lib/saves/hash.ts create mode 100644 frontend/src/lib/saves/indexedDb.test.ts create mode 100644 frontend/src/lib/saves/indexedDb.ts diff --git a/frontend/package.json b/frontend/package.json index 64aefa2..f244a5b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,6 +80,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.4.0", "msw": "^2.12.7", "postcss": "^8.4.38", diff --git a/frontend/src/components/player/GamePlayer.tsx b/frontend/src/components/player/GamePlayer.tsx index af746a8..32580a1 100644 --- a/frontend/src/components/player/GamePlayer.tsx +++ b/frontend/src/components/player/GamePlayer.tsx @@ -1,9 +1,20 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { Fragment, useState, useEffect, useRef, useMemo } from 'react'; import { logger } from '@/lib/logger'; -import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft, MonitorPlay } from 'lucide-react'; +import { + AlertCircle, + Maximize2, + Minimize2, + Loader2, + ArrowLeft, + MonitorPlay, + History, +} from 'lucide-react'; import { LazyImage } from '@/components/common/LazyImage'; import { RufflePlayer } from './RufflePlayer'; import { StreamPlayer } from './StreamPlayer'; +import { SavesDialog } from './SavesDialog'; +import { useGameSaveSync } from '@/hooks/useGameSaveSync'; +import { useAuthStore } from '@/store/auth'; import { PlayerErrorBoundary } from './PlayerErrorBoundary'; import { PlatformIcon } from '@/components/ui/platform-icon'; @@ -59,6 +70,10 @@ function GamePlayerInternal({ const [isFullscreen, setIsFullscreen] = useState(initialFullscreen); // Streams a game Ruffle can play with the original player, for when Ruffle falls short const [preferStream, setPreferStream] = useState(false); + // Signed-in players' saves are kept on their account + const isSignedIn = useAuthStore((state) => state.isAuthenticated); + const saveSync = useGameSaveSync(gameId, isSignedIn && (canPlayInBrowser || streamable)); + const [savesOpen, setSavesOpen] = useState(false); const [iframeError, setIframeError] = useState(null); const [logoError, setLogoError] = useState(false); @@ -234,6 +249,17 @@ function GamePlayerInternal({
+ {isSignedIn && gameId ? ( + + ) : null} {canSwitchPlayer ? (