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

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