From 6ea136083a15fe7485733c23df755fabeb1309bb Mon Sep 17 00:00:00 2001 From: josh Date: Tue, 28 Apr 2026 21:43:00 -0400 Subject: [PATCH] Overhaul cloud save system: fix destructive bugs, add save management UI Stop 401 responses from wiping local saves and force-reloading. Fix logout race condition with final cloud save before token invalidation. Replace hard 3-failure cap with exponential backoff (2min to 30min). Switch cloud save interval from tick-based (30s) to wall-clock (5min). Add cloud save status indicator and Force Save button in TopBar. Show save conflict dialog on login when both local and cloud saves exist. Add cloud save list, download, and delete in Settings. Server now keeps 10 save snapshots per user instead of overwriting a single save. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/routes/saves.ts | 41 ++-- apps/web/src/App.tsx | 19 +- .../components/game/CloudSaveIndicator.tsx | 41 ++++ .../web/src/components/game/CloudSaveList.tsx | 195 ++++++++++++++++++ .../components/game/SaveConflictDialog.tsx | 103 +++++++++ apps/web/src/components/layout/TopBar.tsx | 7 + apps/web/src/hooks/useAuthGate.ts | 22 +- apps/web/src/hooks/useCloudSave.ts | 130 +++++++++--- apps/web/src/lib/api.ts | 3 - apps/web/src/pages/SettingsPage.tsx | 62 +++++- 10 files changed, 559 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/game/CloudSaveIndicator.tsx create mode 100644 apps/web/src/components/game/CloudSaveList.tsx create mode 100644 apps/web/src/components/game/SaveConflictDialog.tsx diff --git a/apps/server/src/routes/saves.ts b/apps/server/src/routes/saves.ts index 2117193..fa5850c 100644 --- a/apps/server/src/routes/saves.ts +++ b/apps/server/src/routes/saves.ts @@ -1,10 +1,12 @@ import { Hono } from 'hono'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, notInArray } from 'drizzle-orm'; import { db } from '../db'; import { saves } from '../db/schema'; import { authMiddleware } from '../middleware/auth'; import type { AppEnv } from '../types'; +const MAX_SAVES_PER_USER = 10; + const savesRouter = new Hono(); savesRouter.use('*', authMiddleware); @@ -68,29 +70,6 @@ savesRouter.put('/', async (c) => { era: string; }>(); - const existing = await db - .select({ id: saves.id }) - .from(saves) - .where(eq(saves.userId, userId)) - .orderBy(desc(saves.updatedAt)) - .limit(1); - - if (existing.length > 0) { - await db - .update(saves) - .set({ - companyName: body.companyName, - saveVersion: body.saveVersion, - gameData: body.gameData, - tickCount: body.tickCount, - era: body.era, - updatedAt: new Date(), - }) - .where(eq(saves.id, existing[0].id)); - - return c.json({ id: existing[0].id, updated: true }); - } - const [newSave] = await db .insert(saves) .values({ @@ -103,6 +82,20 @@ savesRouter.put('/', async (c) => { }) .returning({ id: saves.id }); + const keepIds = await db + .select({ id: saves.id }) + .from(saves) + .where(eq(saves.userId, userId)) + .orderBy(desc(saves.updatedAt)) + .limit(MAX_SAVES_PER_USER); + + const keepSet = keepIds.map((r) => r.id); + if (keepSet.length === MAX_SAVES_PER_USER) { + await db + .delete(saves) + .where(and(eq(saves.userId, userId), notInArray(saves.id, keepSet))); + } + return c.json({ id: newSave.id, created: true }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fd8f266..fbaff8f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { MainLayout } from '@/components/layout/MainLayout'; import { NewGameScreen } from '@/components/game/NewGameScreen'; import { OfflineCatchUp } from '@/components/game/OfflineCatchUp'; import { InviteGateScreen } from '@/components/game/InviteGateScreen'; +import { SaveConflictDialog } from '@/components/game/SaveConflictDialog'; import { useGameLoop } from '@/hooks/useGameLoop'; import { useAuthGate } from '@/hooks/useAuthGate'; import { useCloudSave } from '@/hooks/useCloudSave'; @@ -54,8 +55,10 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => } export function App() { - const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate(); + const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, hasConflict, loadCloudSave, resolveConflict, setRegistered, setNeedsPasswordReset, retry } = useAuthGate(); const companyName = useGameStore((s) => s.meta.companyName); + const currentEra = useGameStore((s) => s.meta.currentEra); + const tickCount = useGameStore((s) => s.meta.tickCount); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const [catchUpTicks, setCatchUpTicks] = useState(null); const [catchUpDone, setCatchUpDone] = useState(false); @@ -71,7 +74,7 @@ export function App() { } }, [companyName, lastTickTimestamp, catchUpDone]); - useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset); + useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset || hasConflict); useCloudSave(); if (authLoading) { @@ -93,6 +96,18 @@ export function App() { ); } + if (hasConflict && cloudSave && companyName) { + return ( + resolveConflict('local')} + onChooseCloud={() => resolveConflict('cloud')} + onNewGame={() => resolveConflict('new')} + /> + ); + } + if (!companyName) { return ; } diff --git a/apps/web/src/components/game/CloudSaveIndicator.tsx b/apps/web/src/components/game/CloudSaveIndicator.tsx new file mode 100644 index 0000000..8ee0979 --- /dev/null +++ b/apps/web/src/components/game/CloudSaveIndicator.tsx @@ -0,0 +1,41 @@ +import { Cloud, CloudOff, Check, AlertTriangle, Loader2 } from 'lucide-react'; +import { useCloudSaveStore, type CloudSaveStatus } from '@/hooks/useCloudSave'; +import { Tooltip } from '@/components/common/Tooltip'; + +function formatTimeAgo(ms: number): string { + const seconds = Math.floor((Date.now() - ms) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +const STATUS_CONFIG: Record = { + idle: { icon: Cloud, color: 'text-surface-500', label: 'Cloud save idle' }, + saving: { icon: Loader2, color: 'text-accent', label: 'Saving to cloud...' }, + success: { icon: Check, color: 'text-success', label: 'Saved to cloud' }, + error: { icon: AlertTriangle, color: 'text-warning', label: 'Cloud save failed' }, + offline: { icon: CloudOff, color: 'text-surface-500', label: 'Cloud save offline' }, +}; + +export function CloudSaveIndicator({ onForceSave }: { onForceSave: () => void }) { + const status = useCloudSaveStore((s) => s.status); + const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime); + + const config = STATUS_CONFIG[status]; + const Icon = config.icon; + const timeLabel = lastSaveTime ? `Last saved: ${formatTimeAgo(lastSaveTime)}` : 'Not yet saved'; + + return ( +
{config.label}
{timeLabel}
Click to save now
}> + +
+ ); +} diff --git a/apps/web/src/components/game/CloudSaveList.tsx b/apps/web/src/components/game/CloudSaveList.tsx new file mode 100644 index 0000000..09dd814 --- /dev/null +++ b/apps/web/src/components/game/CloudSaveList.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect } from 'react'; +import { Download, Trash2, Upload, RefreshCw, Cloud } from 'lucide-react'; +import { api } from '@/lib/api'; +import { useGameStore } from '@/store'; +import { formatDuration } from '@token-empire/shared'; +import { ConfirmModal } from '@/components/common/ConfirmModal'; + +interface SaveEntry { + id: string; + companyName: string; + era: string; + tickCount: number; + updatedAt: string; +} + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function CloudSaveList() { + const [saves, setSaves] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [loadConfirm, setLoadConfirm] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const addNotification = useGameStore((s) => s.addNotification); + + async function fetchSaves() { + setLoading(true); + setError(null); + try { + const { saves: list } = await api.saves.list(); + setSaves(list); + } catch { + setError('Failed to load cloud saves'); + } finally { + setLoading(false); + } + } + + useEffect(() => { fetchSaves(); }, []); + + async function handleLoad(save: SaveEntry) { + try { + const { save: full } = await api.saves.get(save.id); + if (full?.gameData) { + useGameStore.setState(full.gameData as Record); + addNotification({ + title: 'Cloud Save Loaded', + message: `Loaded "${save.companyName}" from cloud.`, + type: 'success', + tick: useGameStore.getState().meta.tickCount, + }); + } + } catch { + addNotification({ + title: 'Load Failed', + message: 'Could not load cloud save.', + type: 'danger', + tick: useGameStore.getState().meta.tickCount, + }); + } + setLoadConfirm(null); + } + + async function handleDownload(save: SaveEntry) { + try { + const { save: full } = await api.saves.get(save.id); + if (full?.gameData) { + const blob = new Blob([JSON.stringify(full.gameData)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `token-empire-cloud-${save.companyName.replace(/\s+/g, '-').toLowerCase()}.json`; + a.click(); + URL.revokeObjectURL(url); + } + } catch { + addNotification({ + title: 'Download Failed', + message: 'Could not download cloud save.', + type: 'danger', + tick: useGameStore.getState().meta.tickCount, + }); + } + } + + async function handleDelete(save: SaveEntry) { + try { + await api.saves.delete(save.id); + setSaves((prev) => prev.filter((s) => s.id !== save.id)); + } catch { + addNotification({ + title: 'Delete Failed', + message: 'Could not delete cloud save.', + type: 'danger', + tick: useGameStore.getState().meta.tickCount, + }); + } + setDeleteConfirm(null); + } + + if (loading) { + return ( +
+ Loading cloud saves... +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (saves.length === 0) { + return ( +
+ No cloud saves yet. +
+ ); + } + + return ( + <> +
+ {saves.map((save) => ( +
+
+
{save.companyName}
+
+ {save.era} · {formatDuration(save.tickCount)} · {timeAgo(save.updatedAt)} +
+
+
+ + + +
+
+ ))} +
+ + {loadConfirm && ( + handleLoad(loadConfirm)} + onCancel={() => setLoadConfirm(null)} + /> + )} + + {deleteConfirm && ( + handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} + /> + )} + + ); +} diff --git a/apps/web/src/components/game/SaveConflictDialog.tsx b/apps/web/src/components/game/SaveConflictDialog.tsx new file mode 100644 index 0000000..ede88df --- /dev/null +++ b/apps/web/src/components/game/SaveConflictDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { Cloud, HardDrive, Plus } from 'lucide-react'; +import { formatDuration } from '@token-empire/shared'; +import type { CloudSaveInfo } from '@/hooks/useAuthGate'; + +interface LocalSaveInfo { + companyName: string; + era: string; + tickCount: number; + lastTickTimestamp: number; +} + +function SaveCard({ icon: Icon, label, companyName, era, tickCount, timestamp, selected, onClick }: { + icon: typeof Cloud; + label: string; + companyName: string; + era: string; + tickCount: number; + timestamp: string; + selected?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function SaveConflictDialog({ localSave, cloudSave, onChooseLocal, onChooseCloud, onNewGame }: { + localSave: LocalSaveInfo; + cloudSave: CloudSaveInfo; + onChooseLocal: () => void; + onChooseCloud: () => void; + onNewGame: () => void; +}) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onChooseLocal(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onChooseLocal]); + + const localDate = new Date(localSave.lastTickTimestamp).toLocaleString(); + const cloudDate = new Date(cloudSave.updatedAt).toLocaleString(); + + return ( +
+
+

Save Conflict

+

+ Both a local save and a cloud save exist. Which would you like to continue with? +

+ +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx index 4056996..ca272da 100644 --- a/apps/web/src/components/layout/TopBar.tsx +++ b/apps/web/src/components/layout/TopBar.tsx @@ -1,8 +1,11 @@ import { type ReactNode, useState } from 'react'; import { Pause, Play, Bell, Share2 } from 'lucide-react'; import { CompanyStatsCard } from '@/components/game/CompanyStatsCard'; +import { CloudSaveIndicator } from '@/components/game/CloudSaveIndicator'; import { NotificationPanel } from '@/components/common/NotificationPanel'; import { useGameStore } from '@/store'; +import { isRegistered } from '@/lib/api'; +import { performCloudSave } from '@/hooks/useCloudSave'; import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared'; import type { GameSpeed } from '@token-empire/shared'; import { Tooltip } from '@/components/common/Tooltip'; @@ -97,6 +100,10 @@ export function TopBar() { ))} + {isRegistered() && ( + + )} + + {timeLabel && {timeLabel}} + + ); +} + function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) { return (