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

@@ -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() {
<>
<GameTick />
<GameSync />
<OfflineReport />
<Navbar
navData={navData}
toggleRain={toggleRain}

View File

@@ -13,6 +13,8 @@ import {
getClickMultiplierFromTree,
getProductionMultiplierFromTree,
getStartBonusFromTree,
offlineEfficiency,
computeOfflineGains,
DEFAULT_STATE,
DEFAULT_GENERATORS,
DEFAULT_EVOLUTION_TREE,
@@ -364,3 +366,90 @@ describe("Evolution Tree", () => {
});
});
});
// --- 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);
});
});

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="gp max-w-sm w-full mx-4 text-center">
<span className="gp-title text-lg!">Pendant ton absence...</span>
<div className="flex flex-col gap-3 mt-2">
<div className="flex justify-between">
<span className="gp-label">Durée</span>
<span className="gp-value">{formatDuration(report.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Efficacité marais</span>
<span className={`gp-value ${effPercent > 50 ? "gp-accent-green" : "gp-accent-amber"}`}>
{effPercent}%
</span>
</div>
<div className="gp-sep" />
<div className="flex justify-between items-center">
<span className="gp-label">Têtards récoltés</span>
<span className="gp-value gp-accent-green text-lg!">
+{formatNumber(report.gains)}
</span>
</div>
{report.efficiency < 0.5 && (
<p className="gp-label text-center">
Le marais s'endort sans toi... Joue activement pour maximiser ta production !
</p>
)}
</div>
<button
onClick={dismiss}
className="gp-btn gp-btn--buy w-full mt-3 py-2!"
>
Retour au marais
</button>
</div>
</div>
);
}

View File

@@ -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,

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