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:
2026-03-28 11:44:59 +01:00
parent 90761b3e13
commit 3ba10dad5f
5 changed files with 284 additions and 15 deletions

View File

@@ -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),
});
},