feat: server-authoritative save — wait for server before game starts
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 18s
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user