Files
ClickerZ/Frontend/src/hooks/useSaveSync.ts
Tetardtek ed8cf87d4e
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
feat: Sprint 3 — Prestige Loop endless
- Migration saves: saveVersion pattern + migrateSave lazy (v1→v2)
- Formule ADN rebalancée: log10 + clamp min 1 + cap bonus ×4
- Prestige Experience: modal fullscreen, preview ADN, stats run, best run
- Arbre V2: 25 nœuds, 3 capstones, post-capstones repeatables (scaling par tranche)
- Convergence évolutif Alpha→Omega (tier system)
- Reset arbre: 1 gratuit/prestige, payant linéaire au-delà
- Milestones prestige: 8 paliers (1→100), cosmétiques exclusifs, bonus gameplay
- balance.ts: constantes centralisées pour playtest
- 136 tests green, 0 regression
2026-03-28 18:24:24 +01:00

149 lines
4.7 KiB
TypeScript

// useSaveSync.ts — Auto-save game state to backend every 30s
// Serveur = autorité. NEVER save before server state is loaded (ready guard).
import { useEffect, useRef, useCallback, useState } from "react";
import { useAuth } from "../context/AuthContext";
import { useGameStore } from "../store/useGameStore";
import type { GameState } from "../core/economy";
import { migrateSave } from "../core/migrateSave";
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
interface SaveSyncOptions {
getGameState: () => GameState;
onLoad: (state: GameState) => void;
playTimeSeconds: number;
}
async function apiRequest(path: string, options: RequestInit = {}) {
const res = await fetch(`${BACKEND_URL}/api${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.warn(`[SaveSync] ${path} failed:`, res.status, body);
return null;
}
return res.json();
}
export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) {
const { user } = useAuth();
const ready = useGameStore((s) => s.ready);
const lastSaveRef = useRef<string | null>(null);
const loadedRef = useRef(false);
const [serverLoaded, setServerLoaded] = useState(false);
// Load save from server on mount (once, only if logged in)
useEffect(() => {
if (loadedRef.current || !user) {
if (!user) setServerLoaded(true);
return;
}
loadedRef.current = true;
apiRequest("/save").then((data) => {
if (data?.gameState) {
const migrated = migrateSave(data.gameState);
onLoad(migrated);
lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Loaded save from server — server is authority (v%d)", migrated.saveVersion);
} else {
console.info("[SaveSync] No server save found — starting fresh");
}
setServerLoaded(true);
}).catch(() => {
console.warn("[SaveSync] Server unreachable — falling back to local");
setServerLoaded(true);
});
}, [onLoad, user]);
// Save function — GUARDED by ready (never save DEFAULT_STATE)
const saveToServer = useCallback(async () => {
if (!user || !useGameStore.getState().ready) return;
const gameState = getGameState();
const result = await apiRequest("/save", {
method: "POST",
body: JSON.stringify({ gameState, playTimeSeconds }),
});
if (result?.lastSave) {
lastSaveRef.current = result.lastSave;
}
}, [getGameState, playTimeSeconds, user]);
// Auto-save interval — only when ready
useEffect(() => {
if (!user || !ready) return undefined;
const interval = setInterval(() => {
saveToServer();
}, SAVE_INTERVAL_MS);
return () => clearInterval(interval);
}, [saveToServer, user, ready]);
// Multi-tab sync: save on blur, reload on focus — only when ready
useEffect(() => {
if (!user) return undefined;
const handleFocus = () => {
// Small delay to let the other tab's blur save complete
setTimeout(() => apiRequest("/save").then((data) => {
if (data?.gameState && data.lastSave) {
if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) {
const migrated = migrateSave(data.gameState);
onLoad(migrated);
lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Reloaded from server on focus");
}
}
}), 500);
};
const handleBlur = () => {
if (!useGameStore.getState().ready) return;
saveToServer();
};
window.addEventListener("focus", handleFocus);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("focus", handleFocus);
window.removeEventListener("blur", handleBlur);
};
}, [user, onLoad, saveToServer]);
// Save on page unload — GUARDED by ready
useEffect(() => {
const handleUnload = () => {
if (!user || !useGameStore.getState().ready) return;
const gameState = getGameState();
const payload = JSON.stringify({ gameState, playTimeSeconds });
fetch(`${BACKEND_URL}/api/save`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
}).catch(() => {});
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
}, [getGameState, playTimeSeconds, user]);
return { saveToServer, lastSave: lastSaveRef.current, serverLoaded };
}