Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s
- 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
149 lines
4.7 KiB
TypeScript
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 };
|
|
}
|