From 3ba10dad5f8ba2898979c069c71c34c701038ac8 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sat, 28 Mar 2026 11:44:59 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20offline=20gains=20=E2=80=94=20courbe=20?= =?UTF-8?q?invers=C3=A9e=202h,=20cap=2025%,=20=C3=A9cran=20r=C3=A9sum?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Frontend/src/App.jsx | 2 + Frontend/src/__tests__/economy.test.ts | 89 +++++++++++++++++++++ Frontend/src/components/OfflineReport.tsx | 65 ++++++++++++++++ Frontend/src/core/economy.ts | 49 ++++++++++++ Frontend/src/store/useGameStore.ts | 94 +++++++++++++++++++---- 5 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 Frontend/src/components/OfflineReport.tsx diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index b9ed1bd..04f30af 100755 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -5,6 +5,7 @@ import Navbar from "./components/navbar"; import Footer from "./components/footer"; import { GameTick } from "./components/GameTick"; import { GameSync } from "./components/GameSync"; +import { OfflineReport } from "./components/OfflineReport"; import navData from "./data/NavBarData.json"; @@ -15,6 +16,7 @@ function App() { <> + { }); }); }); + +// --- Offline gains (courbe inversée) --- + +describe("offlineEfficiency", () => { + it("retourne 1.0 pour absence < 60s (pas offline)", () => { + expect(offlineEfficiency(30_000)).toBe(1); + }); + + it("retourne 1.0 pour absence de 10min (phase 100%)", () => { + expect(offlineEfficiency(10 * 60_000)).toBe(1); + }); + + it("retourne 1.0 à exactement 15min", () => { + expect(offlineEfficiency(15 * 60_000)).toBe(1); + }); + + it("retourne ~0.625 à 30min (milieu decay 1.0→0.25)", () => { + const eff = offlineEfficiency(30 * 60_000); + // 30min = 15min dans la phase decay (15min-1h = 45min total) + // t = 15/45 = 0.333 → eff = 1 - 0.333 * 0.75 = 0.75 + expect(eff).toBeCloseTo(0.75, 1); + }); + + it("retourne 0.25 à exactement 1h (fin du decay)", () => { + expect(offlineEfficiency(60 * 60_000)).toBeCloseTo(0.25); + }); + + it("retourne ~0.125 à 1h30 (milieu 0.25→0)", () => { + const eff = offlineEfficiency(90 * 60_000); + expect(eff).toBeCloseTo(0.125, 1); + }); + + it("retourne 0 à exactement 2h", () => { + expect(offlineEfficiency(2 * 60 * 60_000)).toBe(0); + }); + + it("retourne 0 après 2h (cap)", () => { + expect(offlineEfficiency(5 * 60 * 60_000)).toBe(0); + }); + + it("courbe monotone décroissante", () => { + const points = [0, 10, 15, 30, 45, 60, 90, 120, 180].map( + (min) => offlineEfficiency(min * 60_000) + ); + for (let i = 1; i < points.length; i++) { + expect(points[i]).toBeLessThanOrEqual(points[i - 1]); + } + }); +}); + +describe("computeOfflineGains", () => { + const stateWithProd = { + ...DEFAULT_STATE, + generators: DEFAULT_STATE.generators.map((g, i) => + i === 0 ? { ...g, owned: 10 } : g + ), + lastTick: 0, + lastOnline: 0, + }; + const pps = DEFAULT_GENERATORS[0].baseProduction * 10; // 1/s + + it("gains normaux si absence < 60s", () => { + const gains = computeOfflineGains(stateWithProd, 30_000); + // < threshold → computeIdleGains classique + expect(gains).toBeCloseTo(pps * 30); + }); + + it("gains < idle pur pour absence de 1h", () => { + const gains = computeOfflineGains(stateWithProd, 60 * 60_000); + const fullIdleGains = pps * 3600; + expect(gains).toBeLessThan(fullIdleGains); + expect(gains).toBeGreaterThan(0); + }); + + it("gains = 0 pour absence > 2h si prod constante", () => { + // > 2h : tout tombe à 0%, mais les premières 2h produisent encore + const gains = computeOfflineGains(stateWithProd, 3 * 60 * 60_000); + const gainsAt2h = computeOfflineGains(stateWithProd, 2 * 60 * 60_000); + // gains at 3h should equal gains at 2h (nothing added after 2h) + expect(gains).toBeCloseTo(gainsAt2h, 0); + }); + + it("retourne 0 si aucune production", () => { + const gains = computeOfflineGains({ ...DEFAULT_STATE, lastTick: 0 }, 60 * 60_000); + expect(gains).toBe(0); + }); +}); diff --git a/Frontend/src/components/OfflineReport.tsx b/Frontend/src/components/OfflineReport.tsx new file mode 100644 index 0000000..b790ce2 --- /dev/null +++ b/Frontend/src/components/OfflineReport.tsx @@ -0,0 +1,65 @@ +// OfflineReport.tsx — Écran "Pendant ton absence..." affiché au retour offline + +import { useGameStore } from "../store/useGameStore"; +import { formatNumber } from "../utils/formatNumber"; + +function formatDuration(ms: number): string { + const minutes = Math.floor(ms / 60_000); + if (minutes < 60) return `${minutes}min`; + const hours = Math.floor(minutes / 60); + const remainMinutes = minutes % 60; + return remainMinutes > 0 ? `${hours}h${remainMinutes}min` : `${hours}h`; +} + +export function OfflineReport() { + const report = useGameStore((s) => s.offlineReport); + const dismiss = useGameStore((s) => s.dismissOfflineReport); + + if (!report || !report.wasOffline) return null; + + const effPercent = Math.round(report.efficiency * 100); + + return ( +
+
+ Pendant ton absence... + +
+
+ Durée + {formatDuration(report.duration)} +
+ +
+ Efficacité marais + 50 ? "gp-accent-green" : "gp-accent-amber"}`}> + {effPercent}% + +
+ +
+ +
+ Têtards récoltés + + +{formatNumber(report.gains)} + +
+ + {report.efficiency < 0.5 && ( +

+ Le marais s'endort sans toi... Joue activement pour maximiser ta production ! +

+ )} +
+ + +
+
+ ); +} diff --git a/Frontend/src/core/economy.ts b/Frontend/src/core/economy.ts index bb792fc..3dd3c1e 100644 --- a/Frontend/src/core/economy.ts +++ b/Frontend/src/core/economy.ts @@ -26,6 +26,7 @@ export interface GameState { clickMultiplier: number; generators: Generator[]; lastTick: number; // timestamp ms — lazy calc reference + lastOnline: number; // timestamp ms — dernière activité réelle (tick actif) prestigeCount: number; prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre ancestralDna: number; @@ -95,6 +96,52 @@ export function getStartBonusFromTree(tree: EvolutionNode[]): number { .reduce((sum, n) => sum + n.value, 0); } +// --- Offline gains (courbe inversée) --- + +const OFFLINE_THRESHOLD = 60_000; // 60s — en-dessous = idle normal, au-dessus = offline +const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100% +const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25% +const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0% +const OFFLINE_FLOOR = 0.25; // plancher de la phase de decay + +// Retourne le multiplicateur d'efficacité offline (1.0 → 0.0) +// basé sur le temps d'absence en ms +export function offlineEfficiency(elapsedMs: number): number { + if (elapsedMs <= OFFLINE_THRESHOLD) return 1; // pas offline + if (elapsedMs <= OFFLINE_FULL_MS) return 1; // 0-15min : 100% + if (elapsedMs <= OFFLINE_DECAY_END_MS) { + // 15min-1h : linéaire 1.0 → 0.25 + const t = (elapsedMs - OFFLINE_FULL_MS) / (OFFLINE_DECAY_END_MS - OFFLINE_FULL_MS); + return 1 - t * (1 - OFFLINE_FLOOR); + } + if (elapsedMs <= OFFLINE_ZERO_MS) { + // 1h-2h : linéaire 0.25 → 0.0 + const t = (elapsedMs - OFFLINE_DECAY_END_MS) / (OFFLINE_ZERO_MS - OFFLINE_DECAY_END_MS); + return OFFLINE_FLOOR * (1 - t); + } + return 0; // >2h : rien +} + +// Calcule les gains offline avec la courbe dégressive +// Intègre la courbe par tranches de 1 minute pour plus de précision +export function computeOfflineGains(state: GameState, now: number): number { + const elapsed = now - state.lastTick; + if (elapsed <= OFFLINE_THRESHOLD) return computeIdleGains(state, now); + + const pps = totalProductionPerSecond(state); + if (pps <= 0) return 0; + + // Intégration par tranches de 60s + const STEP = 60_000; + let total = 0; + for (let t = 0; t < elapsed; t += STEP) { + const chunk = Math.min(STEP, elapsed - t); + const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche + total += pps * (chunk / 1000) * eff; + } + return total; +} + // --- Core economy (mis à jour pour intégrer l'arbre) --- // Coût d'achat du N-ième générateur : baseCost × 1.15^owned @@ -183,6 +230,7 @@ export function applyPrestige(state: GameState): GameState { ancestralDna: state.ancestralDna + dnaGained, lifetimeTadpoles: 0, lastTick: Date.now(), + lastOnline: Date.now(), // evolutionTree persiste — jamais reset }; } @@ -201,6 +249,7 @@ export const DEFAULT_STATE: GameState = { clickMultiplier: 1, generators: DEFAULT_GENERATORS, lastTick: Date.now(), + lastOnline: Date.now(), prestigeCount: 0, prestigeMultiplier: 1, ancestralDna: 0, diff --git a/Frontend/src/store/useGameStore.ts b/Frontend/src/store/useGameStore.ts index 90e7f4f..59d87aa 100644 --- a/Frontend/src/store/useGameStore.ts +++ b/Frontend/src/store/useGameStore.ts @@ -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((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((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), }); },