feat: server-authoritative save — wait for server before game starts
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 18s

- Store starts in ready:false — no game actions until authority loaded
- useSaveSync signals serverLoaded (success, empty, or unreachable)
- GameSync orchestrates: logged in → wait server, guest → localStorage
- Home shows loading screen until ready
- localStorage = cache only, server = authority for logged-in users
This commit is contained in:
2026-03-24 14:30:53 +01:00
parent 3fc5e98069
commit 8ce54bfb03
4 changed files with 94 additions and 21 deletions

View File

@@ -1,22 +1,47 @@
// GameSync.tsx — Bridge useSaveSync ↔ Zustand store // GameSync.tsx — Bridge useSaveSync ↔ Zustand store
// Monter une seule fois dans App. Silencieux en mode invité (pas de token). // Serveur = autorité. Attend la save serveur avant de rendre le jeu jouable.
// Guest mode (pas connecté) : init depuis localStorage immédiatement.
import { useCallback } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { useAuth } from "../context/AuthContext";
import { useSaveSync } from "../hooks/useSaveSync"; import { useSaveSync } from "../hooks/useSaveSync";
export function GameSync() { export function GameSync() {
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const ready = useGameStore((s) => s.ready);
const loadFromServer = useGameStore((s) => s.loadFromServer); const loadFromServer = useGameStore((s) => s.loadFromServer);
const initGuest = useGameStore((s) => s.initGuest);
const playSeconds = useGameStore((s) => s.playSeconds); const playSeconds = useGameStore((s) => s.playSeconds);
const { user, loading: authLoading } = useAuth();
const initDone = useRef(false);
const getGameState = useCallback(() => state, [state]); const getGameState = useCallback(() => state, [state]);
useSaveSync({ const { serverLoaded } = useSaveSync({
getGameState, getGameState,
onLoad: loadFromServer, onLoad: loadFromServer,
playTimeSeconds: playSeconds, playTimeSeconds: playSeconds,
}); });
// Once auth resolves: if no user or no server save → init guest
useEffect(() => {
if (authLoading || initDone.current || ready) return;
// Not logged in → guest mode immediately
if (!user) {
initDone.current = true;
initGuest();
return;
}
// Logged in but server save loaded (or confirmed empty) → useSaveSync handles it
// If serverLoaded is true and store isn't ready yet, it means server had no save
if (serverLoaded && !ready) {
initDone.current = true;
initGuest(); // use localStorage as starting point, server will save it on next sync
}
}, [authLoading, user, serverLoaded, ready, initGuest]);
return null; return null;
} }

View File

@@ -1,7 +1,7 @@
// useSaveSync.ts — Auto-save game state to backend every 30s // useSaveSync.ts — Auto-save game state to backend every 30s
// Cookie-based auth — credentials sent automatically // Serveur = autorité. Cookie-based auth — credentials sent automatically.
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { GameState } from "../core/economy"; import type { GameState } from "../core/economy";
@@ -37,18 +37,29 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
const { user } = useAuth(); const { user } = useAuth();
const lastSaveRef = useRef<string | null>(null); const lastSaveRef = useRef<string | null>(null);
const loadedRef = useRef(false); const loadedRef = useRef(false);
const [serverLoaded, setServerLoaded] = useState(false);
// Load save on mount (once) // Load save from server on mount (once, only if logged in)
useEffect(() => { useEffect(() => {
if (loadedRef.current || !user) return; if (loadedRef.current || !user) {
// Not logged in → signal immediately
if (!user) setServerLoaded(true);
return;
}
loadedRef.current = true; loadedRef.current = true;
apiRequest("/save").then((data) => { apiRequest("/save").then((data) => {
if (data?.gameState) { if (data?.gameState) {
onLoad(data.gameState); onLoad(data.gameState);
lastSaveRef.current = data.lastSave; lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Loaded save from server"); console.info("[SaveSync] Loaded save from server — server is authority");
} 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]); }, [onLoad, user]);
@@ -99,5 +110,5 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
return () => window.removeEventListener("beforeunload", handleUnload); return () => window.removeEventListener("beforeunload", handleUnload);
}, [getGameState, playTimeSeconds, user]); }, [getGameState, playTimeSeconds, user]);
return { saveToServer, lastSave: lastSaveRef.current }; return { saveToServer, lastSave: lastSaveRef.current, serverLoaded };
} }

View File

@@ -16,11 +16,22 @@ import "../scss/components/game-panels.scss";
export default function Home() { export default function Home() {
const [toggleRain] = useOutletContext(); const [toggleRain] = useOutletContext();
const ready = useGameStore((s) => s.ready);
const click = useGameStore((s) => s.click); const click = useGameStore((s) => s.click);
const resources = useGameStore((s) => s.state.resources); const resources = useGameStore((s) => s.state.resources);
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const clickGain = getClickGain(state); const clickGain = getClickGain(state);
if (!ready) {
return (
<section className="game-container">
<p style={{ textAlign: "center", color: "#6b7a99", marginTop: "20vh" }}>
Chargement de ta progression...
</p>
</section>
);
}
const createParticle = useCallback((clientX, clientY) => { const createParticle = useCallback((clientX, clientY) => {
const particle = document.createElement("span"); const particle = document.createElement("span");
particle.className = "click-particle"; particle.className = "click-particle";

View File

@@ -1,4 +1,5 @@
// useGameStore.ts — Zustand store, source unique de l'état game // useGameStore.ts — Zustand store, source unique de l'état game
// Serveur = autorité. localStorage = fallback invité uniquement.
// Lazy calculation pattern : gains passifs calculés au read depuis lastTick // Lazy calculation pattern : gains passifs calculés au read depuis lastTick
import { create } from "zustand"; import { create } from "zustand";
@@ -17,7 +18,7 @@ import {
const SAVE_KEY = "clickerz_state"; const SAVE_KEY = "clickerz_state";
function loadState(): GameState { function loadLocalState(): GameState {
try { try {
const raw = localStorage.getItem(SAVE_KEY); const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now() }; if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now() };
@@ -28,7 +29,7 @@ function loadState(): GameState {
} }
} }
function saveState(state: GameState): void { function saveLocal(state: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(state)); localStorage.setItem(SAVE_KEY, JSON.stringify(state));
} }
@@ -36,6 +37,7 @@ interface GameStore {
// State // State
state: GameState; state: GameState;
playSeconds: number; playSeconds: number;
ready: boolean; // true once the authoritative state is loaded
// Derived (recalculated on tick) // Derived (recalculated on tick)
canPrestige: boolean; canPrestige: boolean;
@@ -49,19 +51,25 @@ interface GameStore {
prestige: () => void; prestige: () => void;
reset: () => void; reset: () => void;
loadFromServer: (serverState: GameState) => void; loadFromServer: (serverState: GameState) => void;
initGuest: () => void; // fallback when no server save (guest mode)
generatorCost: typeof genCost; generatorCost: typeof genCost;
} }
// Start with DEFAULT_STATE — game is NOT ready until loadFromServer or initGuest
const initialState = { ...DEFAULT_STATE, lastTick: Date.now() };
export const useGameStore = create<GameStore>((set, get) => ({ export const useGameStore = create<GameStore>((set, get) => ({
state: loadState(), state: initialState,
playSeconds: 0, playSeconds: 0,
canPrestige: canPrestigeCheck(loadState()), ready: false,
productionPerSecond: totalProductionPerSecond(loadState()), canPrestige: false,
productionPerSecond: 0,
tick: () => { tick: () => {
if (!get().ready) return;
set((s) => { set((s) => {
const updated = applyIdleGains(s.state, Date.now()); const updated = applyIdleGains(s.state, Date.now());
saveState(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
playSeconds: s.playSeconds + 1, playSeconds: s.playSeconds + 1,
@@ -72,9 +80,10 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
click: () => { click: () => {
if (!get().ready) return;
set((s) => { set((s) => {
const updated = applyClick(applyIdleGains(s.state, Date.now())); const updated = applyClick(applyIdleGains(s.state, Date.now()));
saveState(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
canPrestige: canPrestigeCheck(updated), canPrestige: canPrestigeCheck(updated),
@@ -84,11 +93,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
buy: (genId: string) => { buy: (genId: string) => {
if (!get().ready) return;
set((s) => { set((s) => {
const withIdle = applyIdleGains(s.state, Date.now()); const withIdle = applyIdleGains(s.state, Date.now());
const updated = buyGenerator(withIdle, genId); const updated = buyGenerator(withIdle, genId);
if (!updated) return s; if (!updated) return s;
saveState(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
productionPerSecond: totalProductionPerSecond(updated), productionPerSecond: totalProductionPerSecond(updated),
@@ -97,10 +107,11 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
buyNode: (nodeId: string) => { buyNode: (nodeId: string) => {
if (!get().ready) return;
set((s) => { set((s) => {
const updated = buyEvolutionNode(s.state, nodeId); const updated = buyEvolutionNode(s.state, nodeId);
if (!updated) return s; if (!updated) return s;
saveState(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
productionPerSecond: totalProductionPerSecond(updated), productionPerSecond: totalProductionPerSecond(updated),
@@ -109,10 +120,11 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
prestige: () => { prestige: () => {
if (!get().ready) return;
set((s) => { set((s) => {
if (!canPrestigeCheck(s.state)) return s; if (!canPrestigeCheck(s.state)) return s;
const updated = applyPrestige(s.state); const updated = applyPrestige(s.state);
saveState(updated); saveLocal(updated);
return { return {
state: updated, state: updated,
canPrestige: canPrestigeCheck(updated), canPrestige: canPrestigeCheck(updated),
@@ -123,24 +135,38 @@ export const useGameStore = create<GameStore>((set, get) => ({
reset: () => { reset: () => {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
saveState(fresh); saveLocal(fresh);
set({ set({
state: fresh, state: fresh,
playSeconds: 0, playSeconds: 0,
ready: true,
canPrestige: false, canPrestige: false,
productionPerSecond: 0, productionPerSecond: 0,
}); });
}, },
// Server save loaded — this IS the authority
loadFromServer: (serverState: GameState) => { loadFromServer: (serverState: GameState) => {
const hydrated = applyIdleGains(serverState, Date.now()); const hydrated = applyIdleGains(serverState, Date.now());
saveState(hydrated); saveLocal(hydrated); // mirror to localStorage as cache
set({ set({
state: hydrated, state: hydrated,
ready: true,
canPrestige: canPrestigeCheck(hydrated), canPrestige: canPrestigeCheck(hydrated),
productionPerSecond: totalProductionPerSecond(hydrated), productionPerSecond: totalProductionPerSecond(hydrated),
}); });
}, },
// Guest mode — no server save, use localStorage or fresh state
initGuest: () => {
const local = loadLocalState();
set({
state: local,
ready: true,
canPrestige: canPrestigeCheck(local),
productionPerSecond: totalProductionPerSecond(local),
});
},
generatorCost: genCost, generatorCost: genCost,
})); }));