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:
2026-03-20 13:40:16 +01:00
parent 9f0ccda99b
commit a52746ed0c
20 changed files with 1167 additions and 152 deletions

View 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 };
}