feat(sprint1-step3b): backend save system + anti-cheat + données rattrapées
- game_saves table + migration 002 (JSON state, anti-cheat metadata) - saveControllers.js : load/save avec validation delta ressources (750k/s × 1.1) - GameSaveManager : upsert MySQL ON DUPLICATE KEY UPDATE - useSaveSync hook : auto-save 30s + keepalive beforeunload + guest fallback - save-validation.test.ts : 8 tests anti-cheat - economy.ts : arbre d'évolution 5 nœuds + prestige ADN (rattrapage step 2) - economy.test.ts : +40 tests (évolution tree, multipliers, start bonus) - GDD + SPRINT1.md : docs sprint complètes - Rethème data : shop.json, Achievements.json, Cookie, Legal (rattrapage step 1)
This commit is contained in:
115
Frontend/src/hooks/useSaveSync.ts
Normal file
115
Frontend/src/hooks/useSaveSync.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// useSaveSync.ts — Auto-save game state to backend every 30s
|
||||
// Requires JWT token in localStorage (set by auth flow)
|
||||
// Falls back silently if no token (guest mode)
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { GameState } from "../core/economy";
|
||||
|
||||
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 token = localStorage.getItem("token");
|
||||
if (!token) return null;
|
||||
|
||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": token,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
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 lastSaveRef = useRef<string | null>(null);
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
// Load save on mount (once)
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
apiRequest("/save").then((data) => {
|
||||
if (data?.gameState) {
|
||||
onLoad(data.gameState);
|
||||
lastSaveRef.current = data.lastSave;
|
||||
console.info("[SaveSync] Loaded save from server");
|
||||
}
|
||||
});
|
||||
}, [onLoad]);
|
||||
|
||||
// Save function
|
||||
const saveToServer = useCallback(async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) 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]);
|
||||
|
||||
// Auto-save interval
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return undefined;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveToServer();
|
||||
}, SAVE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [saveToServer]);
|
||||
|
||||
// Save on page unload
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
const gameState = getGameState();
|
||||
const payload = JSON.stringify({ gameState, playTimeSeconds });
|
||||
|
||||
// Use fetch with keepalive for reliable save on tab close
|
||||
// (sendBeacon doesn't support custom headers)
|
||||
fetch(`${BACKEND_URL}/api/save`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-auth-token": token,
|
||||
},
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||
}, [getGameState, playTimeSeconds]);
|
||||
|
||||
return { saveToServer, lastSave: lastSaveRef.current };
|
||||
}
|
||||
Reference in New Issue
Block a user