feat: offline gains — courbe inversée 2h, cap 25%, écran résumé
offlineEfficiency() : 100% (0-15min) → 25% (1h) → 0% (2h). computeOfflineGains() intègre la courbe par tranches de 1min. GameState.lastOnline ajouté, store hydrate avec offline report. OfflineReport.tsx affiché au retour si absence > 60s. 13 nouveaux tests (66 total, tous passent).
This commit is contained in:
@@ -14,18 +14,23 @@ import {
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
generatorCost as genCost,
|
||||
computeOfflineGains,
|
||||
offlineEfficiency,
|
||||
} from "../core/economy";
|
||||
|
||||
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() };
|
||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
const saved = JSON.parse(raw) as GameState;
|
||||
// Backfill lastOnline for old saves
|
||||
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
|
||||
return applyIdleGains(saved, Date.now());
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now() };
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +38,22 @@ 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; // true once the authoritative state is loaded
|
||||
ready: boolean;
|
||||
|
||||
// Offline report (shown once after load)
|
||||
offlineReport: OfflineReport | null;
|
||||
dismissOfflineReport: () => void;
|
||||
|
||||
// Derived (recalculated on tick)
|
||||
canPrestige: boolean;
|
||||
@@ -51,24 +67,69 @@ interface GameStore {
|
||||
prestige: () => void;
|
||||
reset: () => void;
|
||||
loadFromServer: (serverState: GameState) => void;
|
||||
initGuest: () => void; // fallback when no server save (guest mode)
|
||||
initGuest: () => void;
|
||||
generatorCost: typeof genCost;
|
||||
}
|
||||
|
||||
// Start with DEFAULT_STATE — game is NOT ready until loadFromServer or initGuest
|
||||
const initialState = { ...DEFAULT_STATE, lastTick: Date.now() };
|
||||
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
|
||||
// Backfill lastOnline for old saves
|
||||
if (!saved.lastOnline) saved.lastOnline = saved.lastTick;
|
||||
|
||||
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<GameStore>((set, get) => ({
|
||||
state: initialState,
|
||||
playSeconds: 0,
|
||||
ready: false,
|
||||
offlineReport: null,
|
||||
canPrestige: false,
|
||||
productionPerSecond: 0,
|
||||
|
||||
dismissOfflineReport: () => set({ offlineReport: null }),
|
||||
|
||||
tick: () => {
|
||||
if (!get().ready) return;
|
||||
const now = Date.now();
|
||||
set((s) => {
|
||||
const updated = applyIdleGains(s.state, Date.now());
|
||||
const updated = applyIdleGains(s.state, now);
|
||||
// Mark as actively online
|
||||
updated.lastOnline = now;
|
||||
saveLocal(updated);
|
||||
return {
|
||||
state: updated,
|
||||
@@ -134,37 +195,40 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
|
||||
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,
|
||||
});
|
||||
},
|
||||
|
||||
// Server save loaded — this IS the authority
|
||||
loadFromServer: (serverState: GameState) => {
|
||||
const hydrated = applyIdleGains(serverState, Date.now());
|
||||
saveLocal(hydrated); // mirror to localStorage as cache
|
||||
const { state: hydrated, report } = hydrateWithOffline(serverState, Date.now());
|
||||
saveLocal(hydrated);
|
||||
set({
|
||||
state: hydrated,
|
||||
ready: true,
|
||||
offlineReport: report,
|
||||
canPrestige: canPrestigeCheck(hydrated),
|
||||
productionPerSecond: totalProductionPerSecond(hydrated),
|
||||
});
|
||||
},
|
||||
|
||||
// Guest mode — no server save, use localStorage or fresh state
|
||||
initGuest: () => {
|
||||
const local = loadLocalState();
|
||||
const { state: hydrated, report } = hydrateWithOffline(local, Date.now());
|
||||
saveLocal(hydrated);
|
||||
set({
|
||||
state: local,
|
||||
state: hydrated,
|
||||
ready: true,
|
||||
canPrestige: canPrestigeCheck(local),
|
||||
productionPerSecond: totalProductionPerSecond(local),
|
||||
offlineReport: report,
|
||||
canPrestige: canPrestigeCheck(hydrated),
|
||||
productionPerSecond: totalProductionPerSecond(hydrated),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user