// 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 import { create } from "zustand"; import { GameState, DEFAULT_STATE, applyIdleGains, applyClick, getClickGain, getAutoClicksPerSecond, buyGenerator, buyEvolutionNode, resetEvolutionTree, applyPrestige, canPrestige as canPrestigeCheck, totalProductionPerSecond, generatorCost as genCost, computeOfflineGains, offlineEfficiency, } from "../core/economy"; import { computeNewUnlocks, equipCosmetic as equipCosmeticFn, unequipSlot as unequipSlotFn, addToInventory, DEFAULT_COSMETIC_STATE, type CosmeticSlot, } from "../core/cosmetics"; const SAVE_KEY = "clickerz_state"; const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts function loadLocalState(): GameState { try { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; const saved = JSON.parse(raw) as GameState; // Backfill for old saves if (!saved.lastOnline) saved.lastOnline = saved.lastTick; if (!saved.cosmeticInventory) saved.cosmeticInventory = []; if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {}; return applyIdleGains(saved, Date.now()); } catch { return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; } } function saveLocal(state: GameState): void { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } export interface OfflineReport { wasOffline: boolean; duration: number; // ms gains: number; efficiency: number; // 0-1 average } interface GameStore { // State state: GameState; playSeconds: number; ready: boolean; // Offline report (shown once after load) offlineReport: OfflineReport | null; dismissOfflineReport: () => void; // Last click result (for particle feedback) lastClickGain: number; lastClickDouble: boolean; lastClickCrit: boolean; // Derived (recalculated on tick) canPrestige: boolean; productionPerSecond: number; // Actions tick: () => void; click: () => void; buy: (genId: string) => void; buyNode: (nodeId: string) => void; prestige: () => void; resetTree: () => void; equipCosmetic: (cosmeticId: string) => void; unequipCosmetic: (slot: CosmeticSlot) => void; reset: () => void; loadFromServer: (serverState: GameState) => void; initGuest: () => void; generatorCost: typeof genCost; generatorCostWithTree: (gen: Parameters[0]) => number; } function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } { // Backfill for old saves if (!saved.lastOnline) saved.lastOnline = saved.lastTick; if (!saved.cosmeticInventory) saved.cosmeticInventory = []; if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {}; const elapsed = now - saved.lastTick; if (elapsed <= OFFLINE_THRESHOLD) { // Normal idle — no offline report const hydrated = applyIdleGains(saved, now); return { state: { ...hydrated, lastOnline: now }, report: null, }; } // Offline — use degraded curve const gains = computeOfflineGains(saved, now); const pps = totalProductionPerSecond(saved); const fullGains = pps * (elapsed / 1000); const avgEfficiency = fullGains > 0 ? gains / fullGains : 0; const hydrated: GameState = { ...saved, resources: saved.resources + gains, lifetimeTadpoles: saved.lifetimeTadpoles + gains, lastTick: now, lastOnline: now, }; return { state: hydrated, report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency, }, }; } const initialState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; export const useGameStore = create((set, get) => ({ state: initialState, playSeconds: 0, ready: false, offlineReport: null, lastClickGain: 0, lastClickDouble: false, lastClickCrit: false, canPrestige: false, productionPerSecond: 0, dismissOfflineReport: () => set({ offlineReport: null }), tick: () => { if (!get().ready) return; const now = Date.now(); set((s) => { const updated = applyIdleGains(s.state, now); updated.lastOnline = now; // Auto-click from evolution tree const autoClicks = getAutoClicksPerSecond(updated.evolutionTree); if (autoClicks > 0) { const autoGain = getClickGain(updated) * autoClicks; updated.resources += autoGain; updated.lifetimeTadpoles += autoGain; } // Check cosmetic unlocks every 5s if (s.playSeconds % 5 === 0) { const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped }; const newUnlocks = computeNewUnlocks(updated, cosState); if (newUnlocks.length > 0) { const newCos = addToInventory(cosState, newUnlocks); updated.cosmeticInventory = newCos.inventory; } } saveLocal(updated); return { state: updated, playSeconds: s.playSeconds + 1, canPrestige: canPrestigeCheck(updated), productionPerSecond: totalProductionPerSecond(updated), }; }); }, click: () => { if (!get().ready) return; set((s) => { const result = applyClick(applyIdleGains(s.state, Date.now())); saveLocal(result.state); return { state: result.state, lastClickGain: result.gain, lastClickDouble: result.isDouble, lastClickCrit: result.isCrit, canPrestige: canPrestigeCheck(result.state), productionPerSecond: totalProductionPerSecond(result.state), }; }); }, buy: (genId: string) => { if (!get().ready) return; set((s) => { const withIdle = applyIdleGains(s.state, Date.now()); const updated = buyGenerator(withIdle, genId); if (!updated) return s; saveLocal(updated); return { state: updated, productionPerSecond: totalProductionPerSecond(updated), }; }); }, buyNode: (nodeId: string) => { if (!get().ready) return; set((s) => { const updated = buyEvolutionNode(s.state, nodeId); if (!updated) return s; saveLocal(updated); return { state: updated, productionPerSecond: totalProductionPerSecond(updated), }; }); }, prestige: () => { if (!get().ready) return; set((s) => { if (!canPrestigeCheck(s.state)) return s; const updated = applyPrestige(s.state); saveLocal(updated); return { state: updated, canPrestige: canPrestigeCheck(updated), productionPerSecond: totalProductionPerSecond(updated), }; }); }, equipCosmetic: (cosmeticId: string) => { if (!get().ready) return; set((s) => { const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped }; const updated = equipCosmeticFn(cosState, cosmeticId); const newState = { ...s.state, cosmeticEquipped: updated.equipped }; saveLocal(newState); return { state: newState }; }); }, unequipCosmetic: (slot: CosmeticSlot) => { if (!get().ready) return; set((s) => { const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped }; const updated = unequipSlotFn(cosState, slot); const newState = { ...s.state, cosmeticEquipped: updated.equipped }; saveLocal(newState); return { state: newState }; }); }, resetTree: () => { if (!get().ready) return; set((s) => { const updated = resetEvolutionTree(s.state); saveLocal(updated); return { state: updated, productionPerSecond: totalProductionPerSecond(updated), }; }); }, reset: () => { const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; saveLocal(fresh); set({ state: fresh, playSeconds: 0, ready: true, offlineReport: null, canPrestige: false, productionPerSecond: 0, }); }, loadFromServer: (serverState: GameState) => { const { state: hydrated, report } = hydrateWithOffline(serverState, Date.now()); saveLocal(hydrated); set({ state: hydrated, ready: true, offlineReport: report, canPrestige: canPrestigeCheck(hydrated), productionPerSecond: totalProductionPerSecond(hydrated), }); }, initGuest: () => { const local = loadLocalState(); const { state: hydrated, report } = hydrateWithOffline(local, Date.now()); saveLocal(hydrated); set({ state: hydrated, ready: true, offlineReport: report, canPrestige: canPrestigeCheck(hydrated), productionPerSecond: totalProductionPerSecond(hydrated), }); }, generatorCost: genCost, generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree), }));