From ed8cf87d4e2bef8b0f655855bcafbf76cc03fb47 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sat, 28 Mar 2026 18:24:24 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=203=20=E2=80=94=20Prestige=20Loo?= =?UTF-8?q?p=20endless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration saves: saveVersion pattern + migrateSave lazy (v1→v2) - Formule ADN rebalancée: log10 + clamp min 1 + cap bonus ×4 - Prestige Experience: modal fullscreen, preview ADN, stats run, best run - Arbre V2: 25 nœuds, 3 capstones, post-capstones repeatables (scaling par tranche) - Convergence évolutif Alpha→Omega (tier system) - Reset arbre: 1 gratuit/prestige, payant linéaire au-delà - Milestones prestige: 8 paliers (1→100), cosmétiques exclusifs, bonus gameplay - balance.ts: constantes centralisées pour playtest - 136 tests green, 0 regression --- .../database/migrations/003_save_version.sql | 4 + Backend/database/schema.sql | 1 + Backend/src/controllers/saveControllers.js | 6 +- Backend/src/models/GameSaveManager.js | 8 +- Backend/src/services/migrateSave.js | 65 +++ Frontend/src/__tests__/balance.test.ts | 53 +++ Frontend/src/__tests__/economy.test.ts | 84 ++-- Frontend/src/__tests__/migrateSave.test.ts | 123 +++++ Frontend/src/__tests__/milestones.test.ts | 101 ++++ Frontend/src/components/EvolutionTree.tsx | 174 +++++-- Frontend/src/components/MilestonesPanel.tsx | 89 ++++ Frontend/src/components/PrestigePanel.tsx | 27 +- Frontend/src/components/PrestigeScreen.tsx | 182 +++++++ Frontend/src/core/balance.ts | 68 +++ Frontend/src/core/economy.ts | 448 +++++++++++++++--- Frontend/src/core/migrateSave.ts | 142 ++++++ Frontend/src/data/prestigeMilestones.ts | 76 +++ Frontend/src/hooks/useSaveSync.ts | 9 +- Frontend/src/pages/Home.jsx | 5 + Frontend/src/store/useGameStore.ts | 56 ++- docs/GDD.md | 39 ++ docs/SPRINT3.md | 315 ++++++++++++ 22 files changed, 1917 insertions(+), 158 deletions(-) create mode 100644 Backend/database/migrations/003_save_version.sql create mode 100644 Backend/src/services/migrateSave.js create mode 100644 Frontend/src/__tests__/balance.test.ts create mode 100644 Frontend/src/__tests__/migrateSave.test.ts create mode 100644 Frontend/src/__tests__/milestones.test.ts create mode 100644 Frontend/src/components/MilestonesPanel.tsx create mode 100644 Frontend/src/components/PrestigeScreen.tsx create mode 100644 Frontend/src/core/balance.ts create mode 100644 Frontend/src/core/migrateSave.ts create mode 100644 Frontend/src/data/prestigeMilestones.ts create mode 100644 docs/SPRINT3.md diff --git a/Backend/database/migrations/003_save_version.sql b/Backend/database/migrations/003_save_version.sql new file mode 100644 index 0000000..a72138e --- /dev/null +++ b/Backend/database/migrations/003_save_version.sql @@ -0,0 +1,4 @@ +-- Migration 003: Add save_version column for Sprint 3 migration system +-- Safe to run on existing data — defaults to 1 (Sprint 2 saves) + +ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1 AFTER game_state; diff --git a/Backend/database/schema.sql b/Backend/database/schema.sql index fb44c3e..c8e7891 100755 --- a/Backend/database/schema.sql +++ b/Backend/database/schema.sql @@ -16,6 +16,7 @@ CREATE TABLE game_saves ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL UNIQUE, game_state JSON NOT NULL, + save_version INT DEFAULT 1, last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, lifetime_tadpoles BIGINT DEFAULT 0, prestige_count INT DEFAULT 0, diff --git a/Backend/src/controllers/saveControllers.js b/Backend/src/controllers/saveControllers.js index 1134a13..4f59895 100644 --- a/Backend/src/controllers/saveControllers.js +++ b/Backend/src/controllers/saveControllers.js @@ -1,4 +1,5 @@ const tables = require("../tables"); +const { migrateSave } = require("../services/migrateSave"); // --- Anti-cheat validation --- @@ -75,11 +76,14 @@ const load = async (req, res) => { } // game_state est stocké en JSON — MySQL le retourne comme objet si type JSON - const gameState = + const rawState = typeof save.game_state === "string" ? JSON.parse(save.game_state) : save.game_state; + // Migrate on load — lazy migration, never touch DB rows directly + const gameState = migrateSave(rawState); + return res.status(200).json({ gameState, lastSave: save.last_save, diff --git a/Backend/src/models/GameSaveManager.js b/Backend/src/models/GameSaveManager.js index 1e56daf..9594a02 100644 --- a/Backend/src/models/GameSaveManager.js +++ b/Backend/src/models/GameSaveManager.js @@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager { async upsert(userId, gameState, metadata) { const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata; + const saveVersion = gameState.saveVersion ?? 1; const gameStateJson = JSON.stringify(gameState); const [result] = await this.database.query( - `INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds) - VALUES (?, ?, ?, ?, ?) + `INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds) + VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE game_state = VALUES(game_state), + save_version = VALUES(save_version), lifetime_tadpoles = VALUES(lifetime_tadpoles), prestige_count = VALUES(prestige_count), play_time_seconds = VALUES(play_time_seconds), last_save = CURRENT_TIMESTAMP`, - [userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds] + [userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds] ); return result.affectedRows; diff --git a/Backend/src/services/migrateSave.js b/Backend/src/services/migrateSave.js new file mode 100644 index 0000000..94b8925 --- /dev/null +++ b/Backend/src/services/migrateSave.js @@ -0,0 +1,65 @@ +// migrateSave.js — Backend save migration (mirrors Frontend/src/core/migrateSave.ts) +// Applied on load — lazy migration, never touch DB directly. + +const CURRENT_SAVE_VERSION = 2; + +// Default evolution tree (Sprint 2 — 18 nodes) +// Used to merge new nodes into old saves +const DEFAULT_TREE_IDS = [ + "ponte_amelioree", "double_ponte", "ponte_frenetique", "auto_ponte", + "ponte_critique", "maitre_pondeur", + "instinct_gregaire", "symbiose_algale", "courant_profond", "maree_haute", + "ecosysteme_mature", "marais_eternel", + "memoire_genetique", "adn_renforce", "eveil_rapide", "resilience", + "heritage", "transcendance", +]; + +/** + * Migrate a raw game state to the current version. + * Backend only needs structural migration for anti-cheat validation — + * the full tree/generator merge happens on the frontend. + */ +function migrateSave(raw) { + if (!raw || typeof raw !== "object") return raw; + + const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1; + let state = { ...raw }; + + if (version < 2) { + state = migrateV1toV2(state); + } + + return state; +} + +function migrateV1toV2(state) { + state.saveVersion = 2; + + // RunStats + if (!state.runStats) { + state.runStats = { + startedAt: state.lastTick || Date.now(), + tadpolesProduced: 0, + bestRun: null, + }; + } + + // Tree reset fields + if (typeof state.freeResetAvailable !== "boolean") { + state.freeResetAvailable = true; + } + if (typeof state.extraResetsUsed !== "number") { + state.extraResetsUsed = 0; + } + + // Backfill cosmetics + if (!state.lastOnline) state.lastOnline = state.lastTick; + if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = []; + if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") { + state.cosmeticEquipped = {}; + } + + return state; +} + +module.exports = { migrateSave, CURRENT_SAVE_VERSION }; diff --git a/Frontend/src/__tests__/balance.test.ts b/Frontend/src/__tests__/balance.test.ts new file mode 100644 index 0000000..b157c7e --- /dev/null +++ b/Frontend/src/__tests__/balance.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { postCapstoneCost, treeResetCost } from "../core/balance"; + +describe("postCapstoneCost", () => { + it("first purchase = base cost (no multiplier)", () => { + expect(postCapstoneCost(500, 0)).toBe(500); + }); + + it("applies ×1.5 for purchases 1-5", () => { + expect(postCapstoneCost(500, 1)).toBe(750); + expect(postCapstoneCost(500, 2)).toBe(Math.floor(500 * 1.5 * 1.5)); + }); + + it("uses ×1.8 tier for purchases 5-9", () => { + const at5 = postCapstoneCost(500, 5); + const at6 = postCapstoneCost(500, 6); + // Ratio should be ~1.8 (floor rounding tolerance ±1) + expect(at6 / at5).toBeCloseTo(1.8, 1); + }); + + it("uses ×2.0 tier for purchases 10+", () => { + const at10 = postCapstoneCost(500, 10); + const at11 = postCapstoneCost(500, 11); + expect(at11 / at10).toBeCloseTo(2.0, 1); + }); + + it("cost always increases", () => { + let prev = 0; + for (let i = 0; i < 15; i++) { + const cost = postCapstoneCost(500, i); + expect(cost).toBeGreaterThan(prev); + prev = cost; + } + }); +}); + +describe("treeResetCost", () => { + it("free reset costs 0", () => { + expect(treeResetCost(true, 0)).toBe(0); + expect(treeResetCost(true, 5)).toBe(0); + }); + + it("first paid reset costs 5 ADN", () => { + expect(treeResetCost(false, 0)).toBe(5); + }); + + it("scales linearly", () => { + expect(treeResetCost(false, 0)).toBe(5); + expect(treeResetCost(false, 1)).toBe(10); + expect(treeResetCost(false, 2)).toBe(15); + expect(treeResetCost(false, 3)).toBe(20); + }); +}); diff --git a/Frontend/src/__tests__/economy.test.ts b/Frontend/src/__tests__/economy.test.ts index 4a36a91..9d80e2c 100644 --- a/Frontend/src/__tests__/economy.test.ts +++ b/Frontend/src/__tests__/economy.test.ts @@ -299,31 +299,51 @@ describe("computePrestigeDna", () => { expect(computePrestigeDna(0)).toBe(0); }); - it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => { - expect(computePrestigeDna(1e9)).toBe(150); + it("retourne 0 sous le seuil de 1M", () => { + expect(computePrestigeDna(999_999)).toBe(0); }); - it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => { - expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2))); + it("retourne 1 (clamp) à exactement 1M têtards", () => { + expect(computePrestigeDna(1e6)).toBe(1); }); - it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => { - const dna1 = computePrestigeDna(1e9); - const dna10 = computePrestigeDna(10e9); - expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1); + it("retourne 50 pour 10M têtards (log10(10) = 1)", () => { + expect(computePrestigeDna(10e6)).toBe(50); + }); + + it("scaling log — 10M→100M donne 2× ADN (log10(100) = 2)", () => { + const dna10m = computePrestigeDna(10e6); + const dna100m = computePrestigeDna(100e6); + expect(dna100m / dna10m).toBeCloseTo(2, 1); + }); + + it("prestige bonus augmente le gain (+5% par prestige)", () => { + const base = computePrestigeDna(10e6, 0); + const with10 = computePrestigeDna(10e6, 10); + expect(with10).toBe(Math.max(1, Math.floor(50 * 1 * 1.5))); // 75 + expect(with10).toBeGreaterThan(base); + }); + + it("prestige bonus cappé à ×4 (80+ prestiges)", () => { + const at80 = computePrestigeDna(10e6, 80); + const at100 = computePrestigeDna(10e6, 100); + expect(at80).toBe(at100); // cap atteint }); }); // --- Arbre d'Évolution 3 voies --- describe("Evolution Tree (3 branches)", () => { - it("arbre a 18 nœuds répartis en 3 branches", () => { + it("arbre V2 : 3 branches + cross (~30 nœuds)", () => { const ponte = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "ponte"); const marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais"); const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation"); - expect(ponte.length).toBe(6); - expect(marais.length).toBe(6); - expect(adaptation.length).toBe(6); + const cross = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "cross"); + expect(ponte.length).toBe(8); + expect(marais.length).toBe(8); + expect(adaptation.length).toBe(8); + expect(cross.length).toBe(1); + expect(DEFAULT_EVOLUTION_TREE.length).toBe(25); }); describe("canBuyEvolutionNode", () => { @@ -363,8 +383,8 @@ describe("Evolution Tree (3 branches)", () => { n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n ), }; - // auto_ponte exclusive_with ponte_frenetique → locked - expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(false); + // concentration exclusive_with ponte_frenetique → locked + expect(canBuyEvolutionNode(state, "concentration")).toBe(false); }); it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => { @@ -375,7 +395,7 @@ describe("Evolution Tree (3 branches)", () => { n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n ), }; - expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(true); + expect(canBuyEvolutionNode(state, "concentration")).toBe(true); expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true); }); }); @@ -399,15 +419,17 @@ describe("Evolution Tree (3 branches)", () => { const state = { ...DEFAULT_STATE, ancestralDna: 50, + prestigeCount: 1, + freeResetAvailable: true, evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => n.id === "ponte_amelioree" || n.id === "instinct_gregaire" ? { ...n, unlocked: true } : n ), }; - // ponte_amelioree (1) + instinct_gregaire (1) = 2 ADN spent + // ponte_amelioree (1) + instinct_gregaire (3) = 4 ADN spent, free reset const result = resetEvolutionTree(state); - expect(result.ancestralDna).toBe(52); + expect(result.ancestralDna).toBe(54); expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true); }); @@ -491,41 +513,27 @@ describe("Evolution Tree (3 branches)", () => { }); }); - describe("unlock_generator (Résilience)", () => { - it("prestige avec Résilience donne 1 Lac Mystique", () => { - const state = { - ...DEFAULT_STATE, - resources: 2_000_000, - generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })), - evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => - n.id === "resilience" ? { ...n, unlocked: true } : n - ), - }; - const result = applyPrestige(state); - const lac = result.generators.find((g) => g.id === "lac"); - expect(lac!.owned).toBe(1); - }); - - it("prestige sans Résilience donne 0 Lac Mystique", () => { + describe("prestige reset generators", () => { + it("prestige remet les générateurs à 0", () => { const state = { ...DEFAULT_STATE, resources: 2_000_000, + lifetimeTadpoles: 2_000_000, generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })), }; const result = applyPrestige(state); - const lac = result.generators.find((g) => g.id === "lac"); - expect(lac!.owned).toBe(0); + expect(result.generators.every((g) => g.owned === 0)).toBe(true); }); }); describe("auto_click (getAutoClicksPerSecond)", () => { - it("retourne 0 si auto_ponte non débloqué", () => { + it("retourne 0 si capstone ponte non débloqué", () => { expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0); }); - it("retourne 1 si auto_ponte débloqué", () => { + it("retourne 1 si capstone Ponte Automatique débloqué", () => { const tree = DEFAULT_EVOLUTION_TREE.map((n) => - n.id === "auto_ponte" ? { ...n, unlocked: true } : n + n.id === "ponte_auto" ? { ...n, unlocked: true } : n ); expect(getAutoClicksPerSecond(tree)).toBe(1); }); diff --git a/Frontend/src/__tests__/migrateSave.test.ts b/Frontend/src/__tests__/migrateSave.test.ts new file mode 100644 index 0000000..effa9ea --- /dev/null +++ b/Frontend/src/__tests__/migrateSave.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { migrateSave } from "../core/migrateSave"; +import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy"; +import { CURRENT_SAVE_VERSION } from "../core/balance"; + +// Minimal Sprint 2 save (v1 — no saveVersion field) +function makeV1Save(overrides: Record = {}) { + return { + resources: 1234, + clickMultiplier: 1, + generators: DEFAULT_GENERATORS.map((g) => ({ ...g, owned: 5 })), + lastTick: Date.now() - 60_000, + lastOnline: Date.now() - 60_000, + prestigeCount: 3, + prestigeMultiplier: 1.3, + ancestralDna: 42, + evolutionTree: DEFAULT_EVOLUTION_TREE.slice(0, 18).map((n, i) => ({ + ...n, + unlocked: i < 2, // first 2 nodes unlocked + })), + lifetimeTadpoles: 5_000_000, + cosmeticInventory: ["hat_lily"], + cosmeticEquipped: { hat: "hat_lily" }, + ...overrides, + }; +} + +describe("migrateSave", () => { + describe("v1 → v2", () => { + it("sets saveVersion to current", () => { + const result = migrateSave(makeV1Save()); + expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION); + }); + + it("adds runStats with defaults", () => { + const result = migrateSave(makeV1Save()); + expect(result.runStats).toBeDefined(); + expect(result.runStats.tadpolesProduced).toBe(0); + expect(result.runStats.bestRun).toBeNull(); + }); + + it("adds freeResetAvailable and extraResetsUsed", () => { + const result = migrateSave(makeV1Save()); + expect(result.freeResetAvailable).toBe(true); + expect(result.extraResetsUsed).toBe(0); + }); + + it("preserves unlocked state of existing tree nodes", () => { + const result = migrateSave(makeV1Save()); + const node0 = result.evolutionTree.find((n) => n.id === "ponte_amelioree"); + const node2 = result.evolutionTree.find((n) => n.id === "ponte_frenetique"); + expect(node0?.unlocked).toBe(true); + expect(node2?.unlocked).toBe(false); + }); + + it("preserves generator owned counts", () => { + const result = migrateSave(makeV1Save()); + expect(result.generators[0].owned).toBe(5); + }); + + it("preserves resources, ancestralDna, prestigeCount", () => { + const result = migrateSave(makeV1Save()); + expect(result.resources).toBe(1234); + expect(result.ancestralDna).toBe(42); + expect(result.prestigeCount).toBe(3); + }); + + it("preserves cosmetics", () => { + const result = migrateSave(makeV1Save()); + expect(result.cosmeticInventory).toContain("hat_lily"); + expect(result.cosmeticEquipped.hat).toBe("hat_lily"); + }); + }); + + describe("backfill missing fields", () => { + it("backfills lastOnline from lastTick", () => { + const save = makeV1Save(); + delete (save as Record).lastOnline; + const result = migrateSave(save); + expect(result.lastOnline).toBe(save.lastTick); + }); + + it("backfills empty cosmeticInventory", () => { + const save = makeV1Save(); + delete (save as Record).cosmeticInventory; + const result = migrateSave(save); + expect(result.cosmeticInventory).toEqual([]); + }); + + it("backfills empty cosmeticEquipped", () => { + const save = makeV1Save(); + delete (save as Record).cosmeticEquipped; + const result = migrateSave(save); + expect(result.cosmeticEquipped).toEqual({}); + }); + }); + + describe("v2 passthrough", () => { + it("does not re-migrate a v2 save", () => { + const v2 = migrateSave(makeV1Save()); + const result = migrateSave(v2 as unknown as Record); + expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION); + expect(result).toEqual(v2); + }); + }); + + describe("edge cases", () => { + it("handles save with no evolutionTree (corrupted)", () => { + const save = makeV1Save(); + delete (save as Record).evolutionTree; + const result = migrateSave(save); + expect(result.evolutionTree.length).toBe(DEFAULT_EVOLUTION_TREE.length); + }); + + it("handles save with no generators (corrupted)", () => { + const save = makeV1Save(); + delete (save as Record).generators; + const result = migrateSave(save); + expect(result.generators.length).toBe(DEFAULT_GENERATORS.length); + expect(result.generators[0].owned).toBe(0); + }); + }); +}); diff --git a/Frontend/src/__tests__/milestones.test.ts b/Frontend/src/__tests__/milestones.test.ts new file mode 100644 index 0000000..d654a13 --- /dev/null +++ b/Frontend/src/__tests__/milestones.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_STATE, + getClaimableMilestones, + getNextMilestone, + claimMilestone, + getMilestoneStartNid, + getMilestoneOfflineBonus, +} from "../core/economy"; + +describe("Prestige Milestones", () => { + it("no claimable milestones at 0 prestiges", () => { + expect(getClaimableMilestones(DEFAULT_STATE)).toEqual([]); + }); + + it("milestone_1 claimable at 1 prestige", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 1 }; + const claimable = getClaimableMilestones(state); + expect(claimable.length).toBe(1); + expect(claimable[0].id).toBe("milestone_1"); + }); + + it("multiple milestones claimable at 5 prestiges", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 5 }; + const claimable = getClaimableMilestones(state); + expect(claimable.length).toBe(3); // 1, 3, 5 + }); + + it("already claimed milestones not returned", () => { + const state = { + ...DEFAULT_STATE, + prestigeCount: 5, + claimedMilestones: ["milestone_1", "milestone_3"], + }; + const claimable = getClaimableMilestones(state); + expect(claimable.length).toBe(1); + expect(claimable[0].id).toBe("milestone_5"); + }); + + it("getNextMilestone returns first unachieved", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 2 }; + const next = getNextMilestone(state); + expect(next?.id).toBe("milestone_3"); + expect(next?.threshold).toBe(3); + }); + + it("getNextMilestone returns null when all achieved", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 200 }; + expect(getNextMilestone(state)).toBeNull(); + }); + + describe("claimMilestone", () => { + it("claims successfully and adds to claimedMilestones", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 1 }; + const result = claimMilestone(state, "milestone_1"); + expect(result).not.toBeNull(); + expect(result!.claimedMilestones).toContain("milestone_1"); + }); + + it("cosmetic reward adds to inventory", () => { + const state = { ...DEFAULT_STATE, prestigeCount: 1 }; + const result = claimMilestone(state, "milestone_1"); + expect(result!.cosmeticInventory).toContain("ribbon"); + }); + + it("cannot claim milestone not yet reached", () => { + const result = claimMilestone(DEFAULT_STATE, "milestone_1"); + expect(result).toBeNull(); + }); + + it("cannot claim already claimed milestone", () => { + const state = { + ...DEFAULT_STATE, + prestigeCount: 1, + claimedMilestones: ["milestone_1"], + }; + const result = claimMilestone(state, "milestone_1"); + expect(result).toBeNull(); + }); + }); + + describe("milestone bonuses", () => { + it("getMilestoneStartNid returns 0 without milestone_5", () => { + expect(getMilestoneStartNid(DEFAULT_STATE)).toBe(0); + }); + + it("getMilestoneStartNid returns 1 with milestone_5 claimed", () => { + const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_5"] }; + expect(getMilestoneStartNid(state)).toBe(1); + }); + + it("getMilestoneOfflineBonus returns 0 without milestone_15", () => { + expect(getMilestoneOfflineBonus(DEFAULT_STATE)).toBe(0); + }); + + it("getMilestoneOfflineBonus returns 0.05 with milestone_15 claimed", () => { + const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_15"] }; + expect(getMilestoneOfflineBonus(state)).toBe(0.05); + }); + }); +}); diff --git a/Frontend/src/components/EvolutionTree.tsx b/Frontend/src/components/EvolutionTree.tsx index a721fcf..224f045 100644 --- a/Frontend/src/components/EvolutionTree.tsx +++ b/Frontend/src/components/EvolutionTree.tsx @@ -1,28 +1,43 @@ -// EvolutionTree.tsx — Arbre d'Évolution 3 voies (permanent — jamais reset par prestige) +// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3) +// 3 branches + capstones + post-capstone repeatables + Convergence évolutif import { useGameStore } from "../store/useGameStore"; -import { canBuyEvolutionNode, getSpentDna } from "../core/economy"; +import { + canBuyEvolutionNode, + getSpentDna, + getTreeResetCost, + canResetTree, + getRepeatableCost, + canUpgradeConvergence, +} from "../core/economy"; import type { EvolutionNode, Branch } from "../core/economy"; +import { formatNumber } from "../utils/formatNumber"; -const EFFECT_LABELS: Record string> = { +const EFFECT_LABELS: Record string> = { click_multiplier: (v) => `x${v} ponte`, production_multiplier: (v) => `x${v} production`, - start_bonus: (v) => `+${v} têtards au départ`, - unlock_generator: () => `Lac Mystique dès le début`, + start_bonus: (v) => `+${v} tetards au depart`, + unlock_generator: () => `Lac Mystique des le debut`, double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`, - auto_click: (v) => `${v} auto-ponte/s`, + auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`, + auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`, crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`, generator_boost: (v) => `x${v} Nid`, - cost_reduction: (v) => `-${(v * 100).toFixed(0)}% coût générateurs`, + generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`, + cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`, prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`, - offline_boost: (v) => `+${(v * 100).toFixed(0)}% gains offline`, - prestige_threshold_reduction: (v) => `Prestige à ${((1 - v) * 100).toFixed(0)}% du seuil`, + offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`, + offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`, + prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`, + all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`, + post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`, }; -const BRANCH_CONFIG: Record = { - ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" }, - marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" }, - adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" }, +const BRANCH_CONFIG: Record = { + ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" }, + marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" }, + adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" }, + cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" }, }; function NodeRow({ @@ -36,36 +51,62 @@ function NodeRow({ isExcluded: boolean; onBuy: () => void; }) { + const isCapstone = node.capstone; + const isRepeatable = node.repeatable; + const purchased = node.purchased ?? 0; + const rowClass = node.unlocked - ? "gp-row gp-row--unlocked" + ? isCapstone + ? "gp-row gp-row--unlocked border-amber-400/40!" + : "gp-row gp-row--unlocked" : isExcluded ? "gp-row gp-row--locked opacity-30!" : canBuy - ? "gp-row gp-row--evolution" + ? isCapstone + ? "gp-row gp-row--evolution border-amber-400/30!" + : "gp-row gp-row--evolution" : "gp-row gp-row--locked"; + const cost = isRepeatable && node.unlocked + ? getRepeatableCost(node) + : isRepeatable + ? node.cost + : node.cost; + return (
+ {isCapstone && } {node.name} + {isRepeatable && node.unlocked && ( + x{purchased} + )} {node.exclusive_with && !node.unlocked && !isExcluded && ( OU )}
- {EFFECT_LABELS[node.effect]?.(node.value) ?? node.effect} + {EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}
- {node.unlocked ? ( + {node.unlocked && !isRepeatable ? ( OK + ) : node.unlocked && isRepeatable ? ( + ) : isExcluded ? ( - verrouillé + verrouille ) : ( )}
@@ -99,6 +140,74 @@ function BranchColumn({ branch }: { branch: Branch }) { ); } +function ConvergenceSection() { + const state = useGameStore((s) => s.state); + const buyNode = useGameStore((s) => s.buyNode); + const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode); + const conv = state.evolutionTree.find((n) => n.id === "convergence"); + + if (!conv) return null; + + const canBuy = canBuyEvolutionNode(state, "convergence"); + const canUpgrade = canUpgradeConvergence(state); + const tier = conv.tier ?? 1; + const maxTier = conv.maxTier ?? 2; + const tierName = tier >= 2 ? "Omega" : "Alpha"; + + return ( +
+ + Convergence {conv.unlocked ? tierName : ""} + + {conv.unlocked ? ( +
+
+
+ + {tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier}) + + + {tier >= 2 + ? "+10% tous effets + -20% cout post-capstones" + : "+10% a tous les effets de l'arbre" + } + +
+ OK +
+ {tier < maxTier && ( + + )} +
+ ) : ( +
+
+ Convergence Alpha + +10% a tous les effets de l'arbre + Requis : 1 capstone + tier 3 d'une 2e branche +
+ +
+ )} +
+ ); +} + export function EvolutionTree() { const state = useGameStore((s) => s.state); const resetTree = useGameStore((s) => s.resetTree); @@ -108,13 +217,16 @@ export function EvolutionTree() { const spentDna = getSpentDna(evolutionTree); const hasUnlocked = spentDna > 0; + const resetCost = getTreeResetCost(state); + const canReset = canResetTree(state); const handleReset = () => { - if (!hasUnlocked) return; + if (!canReset) return; + const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)"; const confirmed = window.confirm( - `Réinitialiser l'Arbre d'Évolution ?\n\n` + - `Tu récupères ${spentDna} ADN Ancestral.\n` + - `Tous les nœuds seront verrouillés.\n\n` + + `Reinitialiser l'Arbre d'Evolution ?\n\n` + + `Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` + + `Tous les noeuds seront verrouilles.\n\n` + `Confirmer ?` ); if (confirmed) resetTree(); @@ -123,16 +235,21 @@ export function EvolutionTree() { return (
- Évolution + Evolution
- {ancestralDna} ADN + {formatNumber(ancestralDna)} ADN {hasUnlocked && ( )}
@@ -142,6 +259,7 @@ export function EvolutionTree() {
+
); } diff --git a/Frontend/src/components/MilestonesPanel.tsx b/Frontend/src/components/MilestonesPanel.tsx new file mode 100644 index 0000000..6b2633c --- /dev/null +++ b/Frontend/src/components/MilestonesPanel.tsx @@ -0,0 +1,89 @@ +// MilestonesPanel.tsx — Paliers de prestige (Sprint 3) +// Progress bar vers le prochain milestone, claim button, preview reward + +import { useGameStore } from "../store/useGameStore"; +import { getClaimableMilestones, getNextMilestone } from "../core/economy"; +import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones"; + +export function MilestonesPanel() { + const state = useGameStore((s) => s.state); + const claim = useGameStore((s) => s.claimMilestone); + + if (state.prestigeCount < 1) return null; + + const claimable = getClaimableMilestones(state); + const nextMilestone = getNextMilestone(state); + const totalClaimed = state.claimedMilestones.length; + + return ( +
+
+ Milestones + {totalClaimed}/{PRESTIGE_MILESTONES.length} +
+ + {/* Claimable milestones */} + {claimable.length > 0 && ( +
+ {claimable.map((m) => ( +
+
+ {m.name} + {m.reward.label} +
+ +
+ ))} +
+ )} + + {/* Progress vers le prochain milestone */} + {nextMilestone && ( +
+
+ Prochain : {nextMilestone.name} + + {state.prestigeCount}/{nextMilestone.threshold} + +
+
+
+
+ {nextMilestone.reward.label} +
+ )} + + {/* Tous les milestones réclamés */} + {!nextMilestone && claimable.length === 0 && ( + + Tous les milestones reclames ! + + )} + + {/* Liste compacte des milestones passés */} + {totalClaimed > 0 && claimable.length === 0 && ( +
+ {PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => ( + + {m.threshold} + + ))} +
+ )} +
+ ); +} diff --git a/Frontend/src/components/PrestigePanel.tsx b/Frontend/src/components/PrestigePanel.tsx index ed0bb03..b0f2c34 100644 --- a/Frontend/src/components/PrestigePanel.tsx +++ b/Frontend/src/components/PrestigePanel.tsx @@ -5,28 +5,15 @@ import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from ". import { formatNumber } from "../utils/formatNumber"; export function PrestigePanel() { - const { lifetimeTadpoles } = useGameStore((s) => s.state); - const canPrestige = useGameStore((s) => s.canPrestige); - const prestige = useGameStore((s) => s.prestige); - const state = useGameStore((s) => s.state); - const baseDna = computePrestigeDna(lifetimeTadpoles); + const canPrestige = useGameStore((s) => s.canPrestige); + const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen); + + const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount); const dnaBonus = getPrestigeDnaBonus(state.evolutionTree); const dnaPreview = Math.floor(baseDna * (1 + dnaBonus)); const threshold = getPrestigeThreshold(state); - const handlePrestige = () => { - const confirmed = window.confirm( - `Nouvelle Génération\n\n` + - `Reset : têtards et générateurs à zéro.\n` + - `Récompense : +${dnaPreview} ADN Ancestral\n` + - ` +0.1x multiplicateur permanent\n\n` + - `L'Arbre d'Évolution persiste.\n\n` + - `Confirmer ?` - ); - if (confirmed) prestige(); - }; - return (
Prestige @@ -35,12 +22,12 @@ export function PrestigePanel() { +{dnaPreview} ADN · +0.1x mult -
) : ( - Atteins {formatNumber(threshold)} têtards pour prestige + Atteins {formatNumber(threshold)} tetards pour prestige )}
); diff --git a/Frontend/src/components/PrestigeScreen.tsx b/Frontend/src/components/PrestigeScreen.tsx new file mode 100644 index 0000000..166225e --- /dev/null +++ b/Frontend/src/components/PrestigeScreen.tsx @@ -0,0 +1,182 @@ +// PrestigeScreen.tsx — Écran de prestige fullscreen (Sprint 3) +// Preview ADN, stats de run, comparaison meilleure run, confirmation + +import { useGameStore } from "../store/useGameStore"; +import { + computePrestigeDna, + getPrestigeDnaBonus, + getPrestigeThreshold, +} from "../core/economy"; +import { formatNumber } from "../utils/formatNumber"; + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export function PrestigeScreen() { + const show = useGameStore((s) => s.showPrestigeScreen); + const close = useGameStore((s) => s.closePrestigeScreen); + const prestige = useGameStore((s) => s.prestige); + const state = useGameStore((s) => s.state); + + if (!show) return null; + + const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount); + const dnaBonus = getPrestigeDnaBonus(state.evolutionTree); + const dnaPreview = Math.floor(baseDna * (1 + dnaBonus)); + const threshold = getPrestigeThreshold(state); + const canPrestige = state.lifetimeTadpoles >= threshold; + + // Run stats + const now = Date.now(); + const runDuration = now - state.runStats.startedAt; + const bestRun = state.runStats.bestRun; + + // Comparison with best run + const isBestAdn = !bestRun || dnaPreview > bestRun.adn; + const isBestTadpoles = !bestRun || state.lifetimeTadpoles > bestRun.tadpoles; + + const handlePrestige = () => { + if (canPrestige) prestige(); + }; + + return ( +
+
+ {/* Header */} +
+ Nouvelle Generation +

+ Generation #{state.prestigeCount + 1} +

+
+ +
+ + {/* ADN Preview */} +
+ ADN Ancestral + + +{formatNumber(dnaPreview)} + + {dnaBonus > 0 && ( + + (base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre) + + )} + + Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN + +
+ +
+ + {/* Run Stats */} +
+ Stats de la run + +
+ Duree + {formatDuration(runDuration)} +
+ +
+ Tetards produits + + {formatNumber(state.lifetimeTadpoles)} + {isBestTadpoles && bestRun && " ★"} + +
+ +
+ ADN cette run + + {formatNumber(dnaPreview)} + {isBestAdn && bestRun && " ★"} + +
+ + {bestRun && ( +
+ Vitesse vs meilleure + + {runDuration < bestRun.duration + ? `${Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide` + : runDuration > bestRun.duration + ? `${Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent` + : "identique" + } + +
+ )} +
+ + {bestRun && ( + <> +
+
+ Meilleure run +
+ Duree + {formatDuration(bestRun.duration)} +
+
+ ADN + {formatNumber(bestRun.adn)} +
+
+ + )} + +
+ + {/* Reset info */} +
+

+ Tetards et generateurs remis a zero. +

+

+ Arbre d'Evolution et cosmetiques conserves. +

+

+ +1 reset d'arbre gratuit offert. +

+
+ + {/* Actions */} +
+ + {canPrestige ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/Frontend/src/core/balance.ts b/Frontend/src/core/balance.ts new file mode 100644 index 0000000..04f0536 --- /dev/null +++ b/Frontend/src/core/balance.ts @@ -0,0 +1,68 @@ +// balance.ts — Constantes d'équilibrage centralisées +// Toutes les valeurs de tuning en un seul fichier pour faciliter le playtest. +// Sprint 3 — session brainstorm 2026-03-28 + +// --- Formule ADN prestige --- + +export const PRESTIGE_ADN_BASE = 50; +export const PRESTIGE_ADN_THRESHOLD = 1e6; // 1M têtards minimum pour prestige +export const PRESTIGE_BONUS_PER_PRESTIGE = 0.05; // +5% par prestige +export const PRESTIGE_BONUS_CAP = 3.0; // cap à ×4 total (80 prestiges) +export const PRESTIGE_ADN_MIN = 1; // clamp : jamais 0 ADN si seuil atteint + +// --- Seuil prestige --- + +export const BASE_PRESTIGE_THRESHOLD = 1_000_000; // 1M têtards + +// --- Post-capstone scaling par tranche --- + +export const POST_CAPSTONE_TIERS = [ + { maxPurchases: 5, multiplier: 1.5 }, // achats 1-5 + { maxPurchases: 10, multiplier: 1.8 }, // achats 6-10 + { maxPurchases: Infinity, multiplier: 2.0 }, // achats 11+ +] as const; + +/** + * Calcule le coût du N-ième achat post-capstone repeatable (0-indexed). + * Scaling par tranche : ×1.5 (achats 0-4), ×1.8 (5-9), ×2.0 (10+) + */ +export function postCapstoneCost(baseCost: number, purchased: number): number { + let cost = baseCost; + for (let i = 0; i < purchased; i++) { + if (i < 5) cost *= 1.5; + else if (i < 10) cost *= 1.8; + else cost *= 2.0; + } + return Math.floor(cost); +} + +// --- Reset arbre --- + +export const TREE_RESET_FREE_PER_PRESTIGE = 1; // 1 gratuit par prestige +export const TREE_RESET_EXTRA_COST = 5; // 5 ADN × n pour les resets supplémentaires + +/** + * Coût du prochain reset arbre. + * 1 gratuit par prestige, puis linéaire (5 × n) au-delà. + */ +export function treeResetCost(freeResetAvailable: boolean, extraResetsUsed: number): number { + if (freeResetAvailable) return 0; + return TREE_RESET_EXTRA_COST * (extraResetsUsed + 1); +} + +// --- Offline --- + +export const OFFLINE_THRESHOLD_MS = 60_000; // 60s +export const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100% +export const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25% +export const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0% +export const OFFLINE_FLOOR = 0.25; // plancher decay + +// --- Anti-cheat --- + +export const MAX_PRODUCTION_PER_SECOND = 750_000; +export const CHEAT_MARGIN = 1.1; + +// --- Save version --- + +export const CURRENT_SAVE_VERSION = 2; diff --git a/Frontend/src/core/economy.ts b/Frontend/src/core/economy.ts index 8f706e3..742551a 100644 --- a/Frontend/src/core/economy.ts +++ b/Frontend/src/core/economy.ts @@ -1,6 +1,25 @@ // economy.ts — Core clicker logic (lazy calculation pattern) // Jamais de timer actif : tout est calculé au read depuis lastTick +import { + PRESTIGE_ADN_BASE, + PRESTIGE_ADN_THRESHOLD, + PRESTIGE_BONUS_PER_PRESTIGE, + PRESTIGE_BONUS_CAP, + PRESTIGE_ADN_MIN, + BASE_PRESTIGE_THRESHOLD, + OFFLINE_THRESHOLD_MS as OFFLINE_THRESHOLD, + OFFLINE_FULL_MS, + OFFLINE_DECAY_END_MS, + OFFLINE_ZERO_MS, + OFFLINE_FLOOR, + CURRENT_SAVE_VERSION, + treeResetCost, + postCapstoneCost, +} from "./balance"; +import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones"; +import type { PrestigeMilestone } from "../data/prestigeMilestones"; + export interface Generator { id: string; name: string; @@ -21,27 +40,54 @@ export type EffectType = | "cost_reduction" | "prestige_dna_bonus" | "offline_boost" - | "prestige_threshold_reduction"; + | "prestige_threshold_reduction" + // Sprint 3 — capstones + | "auto_click_scaling" // Ponte Auto — auto-click scale avec upgrades + | "generator_synergy" // Symbiose Totale — +X% par type possédé + | "offline_cap_boost" // Mémoire du Marais — offline cap + durée + // Sprint 3 — Convergence + | "all_effects_boost" // +X% à tous les effets + | "post_capstone_discount"; // -X% coût post-capstones -export type Branch = "ponte" | "marais" | "adaptation"; +export type Branch = "ponte" | "marais" | "adaptation" | "cross"; export interface EvolutionNode { id: string; name: string; - cost: number; // en ADN Ancestral + cost: number; // en ADN Ancestral (base cost for repeatables) effect: EffectType; value: number; unlocked: boolean; requires: string | null; // id du nœud prérequis (null = racine) branch: Branch; exclusive_with?: string; // id du nœud alternatif (pick one) + // Sprint 3 — capstone & repeatable + capstone?: boolean; // nœud capstone (bordure dorée, game-changer) + repeatable?: boolean; // post-capstone achetable en boucle + purchased?: number; // nombre d'achats pour les repeatables + // Sprint 3 — Convergence (nœud évolutif) + tier?: number; // tier actuel (1 = Alpha, 2 = Omega) + maxTier?: number; // tier max + tierUpgradeCost?: number; // coût upgrade au tier suivant + tierUpgradeRequires?: string; // condition pour upgrade ("2_capstones") } export interface CosmeticSlotMap { [slot: string]: string | undefined; } +export interface RunStats { + startedAt: number; // timestamp ms début de la run + tadpolesProduced: number; // têtards produits cette run (tracking granulaire) + bestRun: { + duration: number; // ms + tadpoles: number; + adn: number; + } | null; +} + export interface GameState { + saveVersion: number; resources: number; clickMultiplier: number; generators: Generator[]; @@ -54,46 +100,214 @@ export interface GameState { lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN) cosmeticInventory: string[]; // ids des cosmétiques débloqués cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id + // Sprint 3 + runStats: RunStats; + freeResetAvailable: boolean; // 1 gratuit par prestige + extraResetsUsed: number; // resets payants dans la génération courante + claimedMilestones: string[]; // IDs des milestones réclamés } // --- Arbre d'Évolution --- export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [ - // --- Ponte (click) --- - { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" }, - { id: "double_ponte", name: "Double Ponte", cost: 3, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" }, - { id: "ponte_frenetique", name: "Ponte Frénétique", cost: 8, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "auto_ponte" }, - { id: "auto_ponte", name: "Auto-Ponte", cost: 8, effect: "auto_click", value: 1, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" }, - { id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" }, - { id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" }, + // ═══ PONTE (click) — 10 nœuds ═══ - // --- Marais (production) --- - { id: "instinct_gregaire", name: "Instinct Grégaire", cost: 1, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" }, - { id: "symbiose_algale", name: "Symbiose Algale", cost: 3, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" }, - { id: "courant_profond", name: "Courant Profond", cost: 8, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" }, - { id: "maree_haute", name: "Marée Haute", cost: 8, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" }, - { id: "ecosysteme_mature", name: "Écosystème Mature", cost: 20, effect: "production_multiplier", value: 3, unlocked: false, requires: "courant_profond", branch: "marais" }, - { id: "marais_eternel", name: "Marais Éternel", cost: 40, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" }, + // Tier 1 + { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" }, + // Tier 2 + { id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" }, + // Tier 3 (exclusif) + { id: "ponte_frenetique", name: "Frénésie", cost: 15, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "concentration" }, + { id: "concentration", name: "Concentration", cost: 15, effect: "click_multiplier", value: 4, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" }, + // Tier 3 (parallèle) + { id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" }, + // Tier 4 + { id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" }, + // Capstone + { id: "ponte_auto", name: "Ponte Automatique", cost: 200, effect: "auto_click_scaling", value: 1, unlocked: false, requires: "maitre_pondeur", branch: "ponte", capstone: true }, + // Post-capstone (repeatable) + { id: "ponte_post", name: "+5% auto-ponte", cost: 500, effect: "auto_click", value: 0.05, unlocked: false, requires: "ponte_auto", branch: "ponte", repeatable: true, purchased: 0 }, - // --- Adaptation (utility) --- - { id: "memoire_genetique", name: "Mémoire Génétique", cost: 1, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" }, - { id: "adn_renforce", name: "ADN Renforcé", cost: 3, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" }, - { id: "eveil_rapide", name: "Éveil Rapide", cost: 8, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "resilience" }, - { id: "resilience", name: "Résilience", cost: 8, effect: "unlock_generator", value: 0, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" }, - { id: "heritage", name: "Héritage", cost: 20, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "eveil_rapide", branch: "adaptation" }, - { id: "transcendance", name: "Transcendance", cost: 40, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" }, + // ═══ MARAIS (production) — 10 nœuds ═══ + + // Tier 1 + { id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" }, + // Tier 2 + { id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" }, + // Tier 3 (exclusif) + { id: "courant_profond", name: "Courant Profond", cost: 25, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" }, + { id: "maree_haute", name: "Marée Haute", cost: 25, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" }, + // Tier 3 (parallèle) + { id: "ecosysteme_mature", name: "Écosystème Mature", cost: 25, effect: "production_multiplier", value: 3, unlocked: false, requires: "symbiose_algale", branch: "marais" }, + // Tier 4 + { id: "marais_eternel", name: "Marais Éternel", cost: 60, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" }, + // Capstone + { id: "symbiose_totale", name: "Symbiose Totale", cost: 300, effect: "generator_synergy", value: 0.02, unlocked: false, requires: "marais_eternel", branch: "marais", capstone: true }, + // Post-capstone (repeatable) + { id: "marais_post", name: "+1% synergie", cost: 600, effect: "generator_synergy", value: 0.01, unlocked: false, requires: "symbiose_totale", branch: "marais", repeatable: true, purchased: 0 }, + + // ═══ ADAPTATION (utility) — 10 nœuds ═══ + + // Tier 1 + { id: "memoire_genetique", name: "Mémoire Génétique", cost: 2, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" }, + // Tier 2 + { id: "adn_renforce", name: "ADN Renforcé", cost: 10, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" }, + // Tier 3 (exclusif) + { id: "eveil_rapide", name: "Éveil Rapide", cost: 30, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "mutation_adn" }, + { id: "mutation_adn", name: "Mutation ADN", cost: 30, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" }, + // Tier 3 (parallèle) + { id: "heritage", name: "Héritage", cost: 30, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation" }, + // Tier 4 + { id: "transcendance", name: "Transcendance", cost: 60, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" }, + // Capstone + { id: "memoire_marais", name: "Mémoire du Marais", cost: 250, effect: "offline_cap_boost", value: 0.75, unlocked: false, requires: "transcendance", branch: "adaptation", capstone: true }, + // Post-capstone (repeatable) + { id: "adapt_post", name: "+2% offline cap", cost: 500, effect: "offline_boost", value: 0.02, unlocked: false, requires: "memoire_marais", branch: "adaptation", repeatable: true, purchased: 0 }, + + // ═══ CROSS-BRANCHE — Convergence (nœud évolutif) ═══ + + { id: "convergence", name: "Convergence", cost: 500, effect: "all_effects_boost", value: 0.10, unlocked: false, requires: null, branch: "cross", + tier: 1, maxTier: 2, tierUpgradeCost: 500, tierUpgradeRequires: "2_capstones" }, ]; -// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9)) -export function computePrestigeDna(lifetimeTadpoles: number): number { - return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9)); +// Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus))) +// Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges + +export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: number = 0): number { + if (lifetimeTadpoles < PRESTIGE_ADN_THRESHOLD) return 0; + const ratio = lifetimeTadpoles / PRESTIGE_ADN_THRESHOLD; + if (ratio <= 1) return PRESTIGE_ADN_MIN; + const bonus = Math.min(PRESTIGE_BONUS_PER_PRESTIGE * prestigeCount, PRESTIGE_BONUS_CAP); + const raw = PRESTIGE_ADN_BASE * Math.log10(ratio) * (1 + bonus); + return Math.max(PRESTIGE_ADN_MIN, Math.floor(raw)); +} + +// --- Milestones prestige --- + +// Milestones disponibles mais pas encore réclamés +export function getClaimableMilestones(state: GameState): PrestigeMilestone[] { + return PRESTIGE_MILESTONES.filter( + (m) => state.prestigeCount >= m.threshold && !state.claimedMilestones.includes(m.id) + ); +} + +// Prochain milestone non atteint +export function getNextMilestone(state: GameState): PrestigeMilestone | null { + return PRESTIGE_MILESTONES.find((m) => state.prestigeCount < m.threshold) ?? null; +} + +// Réclamer un milestone +export function claimMilestone(state: GameState, milestoneId: string): GameState | null { + const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId); + if (!milestone) return null; + if (state.prestigeCount < milestone.threshold) return null; + if (state.claimedMilestones.includes(milestoneId)) return null; + + let newState = { + ...state, + claimedMilestones: [...state.claimedMilestones, milestoneId], + }; + + // Appliquer la récompense + if (milestone.reward.type === "cosmetic") { + if (!newState.cosmeticInventory.includes(milestone.reward.cosmeticId)) { + newState = { + ...newState, + cosmeticInventory: [...newState.cosmeticInventory, milestone.reward.cosmeticId], + }; + } + } + // Les bonus gameplay sont appliqués passivement via getMilestoneBonus() + + return newState; +} + +// Bonus gameplay cumulés depuis les milestones réclamés +export function getMilestoneStartNid(state: GameState): number { + const claimed = state.claimedMilestones; + if (claimed.includes("milestone_5")) return 1; // 1 Nid gratuit + return 0; +} + +export function getMilestoneOfflineBonus(state: GameState): number { + const claimed = state.claimedMilestones; + if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap + return 0; +} + +// Compte les capstones débloqués +export function getUnlockedCapstoneCount(tree: EvolutionNode[]): number { + return tree.filter((n) => n.capstone && n.unlocked).length; +} + +// Coût actuel d'un nœud repeatable (scaling par tranche via balance.ts) +export function getRepeatableCost(node: EvolutionNode): number { + if (!node.repeatable) return node.cost; + return postCapstoneCost(node.cost, node.purchased ?? 0); +} + +// Vérifie si le joueur peut acheter Convergence (condition spéciale) +function canBuyConvergence(state: GameState, node: EvolutionNode): boolean { + // Tier 1 : 1 capstone + au moins 1 nœud tier 3 d'une 2e branche + if (!node.unlocked && (node.tier ?? 1) === 1) { + const capstones = getUnlockedCapstoneCount(state.evolutionTree); + if (capstones < 1) return false; + // Check: au moins 1 nœud dans une branche différente de la capstone + const capstoneBranches = new Set( + state.evolutionTree.filter((n) => n.capstone && n.unlocked).map((n) => n.branch) + ); + const otherBranchNodes = state.evolutionTree.filter( + (n) => n.unlocked && !capstoneBranches.has(n.branch) && n.branch !== "cross" && n.cost >= 15 + ); + return otherBranchNodes.length > 0 && state.ancestralDna >= node.cost; + } + return false; +} + +// Vérifie si Convergence peut être upgradé au tier suivant +export function canUpgradeConvergence(state: GameState): boolean { + const conv = state.evolutionTree.find((n) => n.id === "convergence"); + if (!conv || !conv.unlocked) return false; + if ((conv.tier ?? 1) >= (conv.maxTier ?? 2)) return false; + if (conv.tierUpgradeRequires === "2_capstones" && getUnlockedCapstoneCount(state.evolutionTree) < 2) return false; + return state.ancestralDna >= (conv.tierUpgradeCost ?? 500); +} + +// Upgrade Convergence au tier suivant +export function upgradeConvergence(state: GameState): GameState | null { + if (!canUpgradeConvergence(state)) return null; + const conv = state.evolutionTree.find((n) => n.id === "convergence")!; + const cost = conv.tierUpgradeCost ?? 500; + return { + ...state, + ancestralDna: state.ancestralDna - cost, + evolutionTree: state.evolutionTree.map((n) => + n.id === "convergence" + ? { ...n, tier: (n.tier ?? 1) + 1, effect: "post_capstone_discount" as EffectType, value: 0.20 } + : n + ), + }; } // Vérifie si un nœud peut être acheté export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean { const node = state.evolutionTree.find((n) => n.id === nodeId); - if (!node || node.unlocked) return false; - if (state.ancestralDna < node.cost) return false; + if (!node) return false; + + // Convergence a sa propre logique + if (node.id === "convergence") return canBuyConvergence(state, node); + + // Repeatable : toujours achetable si unlocked + prérequis + assez d'ADN + if (node.repeatable && node.unlocked) { + const cost = getRepeatableCost(node); + return state.ancestralDna >= cost; + } + + if (node.unlocked) return false; + + const cost = node.repeatable ? getRepeatableCost(node) : node.cost; + if (state.ancestralDna < cost) return false; + if (node.requires) { const prereq = state.evolutionTree.find((n) => n.id === node.requires); if (!prereq || !prereq.unlocked) return false; @@ -111,31 +325,80 @@ export function buyEvolutionNode(state: GameState, nodeId: string): GameState | if (!canBuyEvolutionNode(state, nodeId)) return null; const node = state.evolutionTree.find((n) => n.id === nodeId)!; + + // Repeatable node — already unlocked, increment purchased + if (node.repeatable && node.unlocked) { + const cost = getRepeatableCost(node); + return { + ...state, + ancestralDna: state.ancestralDna - cost, + evolutionTree: state.evolutionTree.map((n) => + n.id === nodeId ? { ...n, purchased: (n.purchased ?? 0) + 1 } : n + ), + }; + } + + const cost = node.repeatable ? getRepeatableCost(node) : node.cost; return { ...state, - ancestralDna: state.ancestralDna - node.cost, + ancestralDna: state.ancestralDna - cost, evolutionTree: state.evolutionTree.map((n) => n.id === nodeId ? { ...n, unlocked: true } : n ), }; } -// Reset l'arbre — rembourse tout l'ADN dépensé, relock tous les nœuds +// Coût du prochain reset arbre (pour affichage UI) +export function getTreeResetCost(state: GameState): number { + return treeResetCost(state.freeResetAvailable, state.extraResetsUsed); +} + +// Vérifie si le joueur peut reset l'arbre +export function canResetTree(state: GameState): boolean { + if (state.prestigeCount < 1) return false; + const cost = getTreeResetCost(state); + return state.ancestralDna >= cost; +} + +// Reset l'arbre — rembourse l'ADN dépensé (y compris repeatables), déduit le coût du reset export function resetEvolutionTree(state: GameState): GameState { - const spentDna = state.evolutionTree - .filter((n) => n.unlocked) - .reduce((sum, n) => sum + n.cost, 0); + const cost = getTreeResetCost(state); + if (state.ancestralDna < cost) return state; + + const spentDna = getSpentDna(state.evolutionTree); return { ...state, - ancestralDna: state.ancestralDna + spentDna, - evolutionTree: state.evolutionTree.map((n) => ({ ...n, unlocked: false })), + ancestralDna: state.ancestralDna + spentDna - cost, + evolutionTree: state.evolutionTree.map((n) => ({ + ...n, + unlocked: false, + purchased: n.repeatable ? 0 : n.purchased, + tier: n.maxTier ? 1 : n.tier, + })), + freeResetAvailable: state.freeResetAvailable ? false : state.freeResetAvailable, + extraResetsUsed: state.freeResetAvailable ? state.extraResetsUsed : state.extraResetsUsed + 1, }; } -// Compte l'ADN total investi dans l'arbre +// Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades) export function getSpentDna(tree: EvolutionNode[]): number { - return tree.filter((n) => n.unlocked).reduce((sum, n) => sum + n.cost, 0); + let total = 0; + for (const n of tree) { + if (!n.unlocked) continue; + total += n.cost; // coût initial + // Repeatables : somme des coûts de chaque achat + if (n.repeatable && (n.purchased ?? 0) > 0) { + for (let i = 0; i < n.purchased!; i++) { + total += postCapstoneCost(n.cost, i); + } + } + // Convergence tier upgrades + if (n.maxTier && (n.tier ?? 1) > 1) { + total += (n.tierUpgradeCost ?? 0) * ((n.tier ?? 1) - 1); + } + } + return total; } // Calcule le multiplicateur click total depuis l'arbre @@ -166,11 +429,13 @@ export function getDoubleClickChance(tree: EvolutionNode[]): number { .reduce((sum, n) => sum + n.value, 0); } -// Auto-clicks par seconde depuis l'arbre +// Auto-clicks par seconde depuis l'arbre (standard + capstone scaling) export function getAutoClicksPerSecond(tree: EvolutionNode[]): number { - return tree - .filter((n) => n.unlocked && n.effect === "auto_click") + const standard = tree + .filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable) .reduce((sum, n) => sum + n.value, 0); + const scaling = getAutoClickScaling(tree); + return standard + scaling; } // Chance de crit click (0-1), crit = x10 @@ -215,13 +480,48 @@ export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number { .reduce((sum, n) => sum + n.value, 0); } -// --- Offline gains (courbe inversée) --- +// --- Sprint 3 — Nouveaux effets --- -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 +// Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables +export function getAutoClickScaling(tree: EvolutionNode[]): number { + const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked); + if (!capstone) return 0; + const baseAutoClick = capstone.value; // 1/s + // Post-capstone adds flat auto-click value per purchase + const postNode = tree.find((n) => n.id === "ponte_post" && n.unlocked); + const postBonus = postNode ? postNode.value * (postNode.purchased ?? 0) : 0; + return baseAutoClick + postBonus; +} + +// Symbiose Totale (capstone) : +X% par type de générateur possédé +// Retourne le multiplicateur (ex: 5 types × 0.02 = 0.10 → ×1.10) +export function getGeneratorSynergyMultiplier(tree: EvolutionNode[], generators: Generator[]): number { + const synergyNodes = tree.filter((n) => n.unlocked && n.effect === "generator_synergy"); + if (synergyNodes.length === 0) return 1; + const totalSynergyRate = synergyNodes.reduce((sum, n) => { + // For repeatables, each purchase adds to the rate + const extra = n.repeatable ? n.value * (n.purchased ?? 0) : 0; + return sum + n.value + extra; + }, 0); + const typesOwned = generators.filter((g) => g.owned > 0).length; + return 1 + totalSynergyRate * typesOwned; +} + +// Convergence : all_effects_boost — multiplicateur global sur tous les effets de l'arbre +export function getAllEffectsBoost(tree: EvolutionNode[]): number { + const conv = tree.find((n) => n.id === "convergence" && n.unlocked); + if (!conv) return 1; + return 1 + conv.value; // 0.10 = ×1.10 +} + +// Convergence Omega : post_capstone_discount +export function getPostCapstoneDiscount(tree: EvolutionNode[]): number { + const conv = tree.find((n) => n.id === "convergence" && n.unlocked && n.effect === "post_capstone_discount"); + if (!conv) return 0; + return conv.value; // 0.20 = -20% +} + +// --- Offline gains (courbe inversée) --- // Retourne le multiplicateur d'efficacité offline (1.0 → 0.0) // basé sur le temps d'absence en ms @@ -250,7 +550,7 @@ export function computeOfflineGains(state: GameState, now: number): number { const pps = totalProductionPerSecond(state); if (pps <= 0) return 0; - const offlineBoost = 1 + getOfflineBoost(state.evolutionTree); + const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state); // Intégration par tranches de 60s const STEP = 60_000; @@ -276,6 +576,7 @@ export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number { // Production totale par seconde de tous les générateurs export function totalProductionPerSecond(state: GameState): number { const nidBoost = getGeneratorBoostFromTree(state.evolutionTree); + const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators); const base = state.generators.reduce( (sum, gen) => { const boost = gen.id === "nid" ? nidBoost : 1; @@ -284,7 +585,8 @@ export function totalProductionPerSecond(state: GameState): number { 0 ); const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree); - return base * state.prestigeMultiplier * treeMultiplier; + const convergenceBoost = getAllEffectsBoost(state.evolutionTree); + return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost; } // Lazy calculation : ressources accumulées depuis lastTick @@ -301,6 +603,10 @@ export function applyIdleGains(state: GameState, now: number): GameState { resources: state.resources + gains, lifetimeTadpoles: state.lifetimeTadpoles + gains, lastTick: now, + runStats: { + ...state.runStats, + tadpolesProduced: state.runStats.tadpolesProduced + gains, + }, }; } @@ -342,6 +648,10 @@ export function applyClick(state: GameState, rng: number = Math.random()): Click ...state, resources: state.resources + gain, lifetimeTadpoles: state.lifetimeTadpoles + gain, + runStats: { + ...state.runStats, + tadpolesProduced: state.runStats.tadpolesProduced + gain, + }, }, gain, isDouble, @@ -369,7 +679,6 @@ export function buyGenerator(state: GameState, genId: string): GameState | null } // Prestige : reset run, gain ADN, arbre persiste -const BASE_PRESTIGE_THRESHOLD = 1_000_000; export function getPrestigeThreshold(state: GameState): number { const reduction = getPrestigeThresholdReduction(state.evolutionTree); @@ -383,7 +692,7 @@ export function canPrestige(state: GameState): boolean { export function applyPrestige(state: GameState): GameState { const newPrestigeCount = state.prestigeCount + 1; const dnaBonus = getPrestigeDnaBonus(state.evolutionTree); - const baseDna = computePrestigeDna(state.lifetimeTadpoles); + const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount); const dnaGained = Math.floor(baseDna * (1 + dnaBonus)); const startBonus = getStartBonusFromTree(state.evolutionTree); @@ -391,20 +700,42 @@ export function applyPrestige(state: GameState): GameState { const hasUnlockGen = state.evolutionTree.some( (n) => n.unlocked && n.effect === "unlock_generator" ); + // Milestone bonus : Nid gratuit au départ + const milestoneNid = getMilestoneStartNid(state); + + // RunStats : snapshot de la run qui se termine + const now = Date.now(); + const runDuration = now - state.runStats.startedAt; + const bestRun = state.runStats.bestRun; + const newBestRun = + !bestRun || dnaGained > bestRun.adn + ? { duration: runDuration, tadpoles: state.lifetimeTadpoles, adn: dnaGained } + : bestRun; return { ...state, resources: startBonus, generators: state.generators.map((g) => ({ ...g, - owned: hasUnlockGen && g.id === "lac" ? 1 : 0, + owned: + (hasUnlockGen && g.id === "lac") ? 1 : + (milestoneNid > 0 && g.id === "nid") ? milestoneNid : + 0, })), prestigeCount: newPrestigeCount, prestigeMultiplier: 1 + newPrestigeCount * 0.1, ancestralDna: state.ancestralDna + dnaGained, lifetimeTadpoles: 0, - lastTick: Date.now(), - lastOnline: Date.now(), + lastTick: now, + lastOnline: now, + // Sprint 3 — nouvelle run + runStats: { + startedAt: now, + tadpolesProduced: 0, + bestRun: newBestRun, + }, + freeResetAvailable: true, // 1 reset gratuit offert par prestige + extraResetsUsed: 0, // evolutionTree persiste — jamais reset }; } @@ -419,6 +750,7 @@ export const DEFAULT_GENERATORS: Generator[] = [ ]; export const DEFAULT_STATE: GameState = { + saveVersion: CURRENT_SAVE_VERSION, resources: 0, clickMultiplier: 1, generators: DEFAULT_GENERATORS, @@ -431,4 +763,12 @@ export const DEFAULT_STATE: GameState = { lifetimeTadpoles: 0, cosmeticInventory: [], cosmeticEquipped: {}, + runStats: { + startedAt: Date.now(), + tadpolesProduced: 0, + bestRun: null, + }, + freeResetAvailable: true, + extraResetsUsed: 0, + claimedMilestones: [], }; diff --git a/Frontend/src/core/migrateSave.ts b/Frontend/src/core/migrateSave.ts new file mode 100644 index 0000000..0bde37d --- /dev/null +++ b/Frontend/src/core/migrateSave.ts @@ -0,0 +1,142 @@ +// migrateSave.ts — Migration lazy des saves entre versions +// Appliqué au chargement (frontend + backend). Jamais de migration en DB. +// Chaque sprint ajoute un step (v2→v3, etc.) + +import { CURRENT_SAVE_VERSION } from "./balance"; +import type { GameState } from "./economy"; +import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy"; + +/** + * Détecte la version d'une save et applique les migrations nécessaires. + * Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet). + * Sortie : GameState conforme à la version courante. + */ +export function migrateSave(raw: Record): GameState { + const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1; + + let state = raw as Record; + + if (version < 2) { + state = migrateV1toV2(state); + } + + // Futures migrations : + // if (version < 3) state = migrateV2toV3(state); + + return state as unknown as GameState; +} + +/** + * v1 → v2 : Sprint 2 → Sprint 3 + * - Ajoute saveVersion + * - Ajoute runStats (vide) + * - Ajoute freeResetAvailable + extraResetsUsed + * - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants) + * - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline) + */ +function migrateV1toV2(raw: Record): Record { + const state = { ...raw }; + + // saveVersion + state.saveVersion = 2; + + // RunStats (nouveau Sprint 3) + if (!state.runStats) { + state.runStats = { + startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(), + tadpolesProduced: 0, + bestRun: null, + }; + } + + // Reset arbre : 1 gratuit par prestige + if (typeof state.freeResetAvailable !== "boolean") { + state.freeResetAvailable = true; + } + if (typeof state.extraResetsUsed !== "number") { + state.extraResetsUsed = 0; + } + + // Milestones (Sprint 3) + if (!Array.isArray(state.claimedMilestones)) { + state.claimedMilestones = []; + } + + // Backfill champs Sprint 2 potentiellement manquants + if (!state.lastOnline) state.lastOnline = state.lastTick; + if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = []; + if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") { + state.cosmeticEquipped = {}; + } + + // Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux + state.evolutionTree = mergeEvolutionTree( + state.evolutionTree as Array> | undefined + ); + + // Merge générateurs : conserver owned + ajouter les potentiels nouveaux + state.generators = mergeGenerators( + state.generators as Array> | undefined + ); + + return state; +} + +/** + * Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE. + * - Nœuds existants : conserve unlocked state + * - Nœuds nouveaux : ajoutés avec unlocked: false + * - Nœuds supprimés du default : retirés (forward compat) + */ +function mergeEvolutionTree( + savedTree: Array> | undefined +): typeof DEFAULT_EVOLUTION_TREE { + if (!savedTree || !Array.isArray(savedTree)) { + return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n })); + } + + const savedById = new Map( + savedTree.map((n) => [n.id as string, n]) + ); + + return DEFAULT_EVOLUTION_TREE.map((defaultNode) => { + const saved = savedById.get(defaultNode.id); + if (saved) { + // Conserver l'état unlocked, tout le reste vient du default + // (permet de corriger des valeurs rebalancées sans casser les saves) + return { + ...defaultNode, + unlocked: saved.unlocked === true, + }; + } + // Nouveau nœud — ajouté verrouillé + return { ...defaultNode }; + }); +} + +/** + * Merge les générateurs sauvegardés avec DEFAULT_GENERATORS. + * Conserve le owned count, met à jour les stats de base. + */ +function mergeGenerators( + savedGens: Array> | undefined +): typeof DEFAULT_GENERATORS { + if (!savedGens || !Array.isArray(savedGens)) { + return DEFAULT_GENERATORS.map((g) => ({ ...g })); + } + + const savedById = new Map( + savedGens.map((g) => [g.id as string, g]) + ); + + return DEFAULT_GENERATORS.map((defaultGen) => { + const saved = savedById.get(defaultGen.id); + if (saved) { + return { + ...defaultGen, + owned: typeof saved.owned === "number" ? saved.owned : 0, + }; + } + return { ...defaultGen }; + }); +} diff --git a/Frontend/src/data/prestigeMilestones.ts b/Frontend/src/data/prestigeMilestones.ts new file mode 100644 index 0000000..1bd4a7e --- /dev/null +++ b/Frontend/src/data/prestigeMilestones.ts @@ -0,0 +1,76 @@ +// prestigeMilestones.ts — Paliers de prestige (Sprint 3) +// 8 paliers : cosmétiques exclusifs + bonus gameplay légers + +export type MilestoneRewardType = "cosmetic" | "bonus" | "title"; + +export interface PrestigeMilestone { + id: string; + threshold: number; // nombre de prestiges requis + name: string; + description: string; + reward: MilestoneReward; +} + +export type MilestoneReward = + | { type: "cosmetic"; cosmeticId: string; label: string } + | { type: "bonus"; effect: string; value: number; label: string } + | { type: "title"; title: string; label: string }; + +export const PRESTIGE_MILESTONES: PrestigeMilestone[] = [ + { + id: "milestone_1", + threshold: 1, + name: "Premiere Generation", + description: "Premier prestige accompli", + reward: { type: "cosmetic", cosmeticId: "ribbon", label: "Ruban queue" }, + }, + { + id: "milestone_3", + threshold: 3, + name: "Gardien Recurrent", + description: "3 prestiges — la perseverance paie", + reward: { type: "title", title: "Gardien Recurrent", label: "Titre exclusif" }, + }, + { + id: "milestone_5", + threshold: 5, + name: "Nid Offert", + description: "5 prestiges — un coup de pouce au depart", + reward: { type: "bonus", effect: "start_nid", value: 1, label: "1 Nid gratuit au depart" }, + }, + { + id: "milestone_10", + threshold: 10, + name: "Tetard Ancestral", + description: "10 prestiges — la lignee s'affirme", + reward: { type: "cosmetic", cosmeticId: "crown", label: "Couronne doree + skin Ancestral" }, + }, + { + id: "milestone_15", + threshold: 15, + name: "Marais Fidele", + description: "15 prestiges — le marais te reconnait", + reward: { type: "bonus", effect: "offline_cap_perm", value: 0.05, label: "+5% offline cap permanent" }, + }, + { + id: "milestone_25", + threshold: 25, + name: "Gardien Emerite", + description: "25 prestiges — tissu d'algues ancestrales", + reward: { type: "cosmetic", cosmeticId: "cape_algae", label: "Cape d'algues ancestrales" }, + }, + { + id: "milestone_50", + threshold: 50, + name: "Legende du Marais", + description: "50 prestiges — la legende est toi", + reward: { type: "cosmetic", cosmeticId: "flame_tail", label: "Queue enflamee + particules dorees" }, + }, + { + id: "milestone_100", + threshold: 100, + name: "Tetard Primordial", + description: "100 prestiges — retour aux origines", + reward: { type: "cosmetic", cosmeticId: "primordial_body", label: "Skin Tetard Primordial (full set)" }, + }, +]; diff --git a/Frontend/src/hooks/useSaveSync.ts b/Frontend/src/hooks/useSaveSync.ts index a578cd0..0e435db 100644 --- a/Frontend/src/hooks/useSaveSync.ts +++ b/Frontend/src/hooks/useSaveSync.ts @@ -5,6 +5,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { useAuth } from "../context/AuthContext"; import { useGameStore } from "../store/useGameStore"; import type { GameState } from "../core/economy"; +import { migrateSave } from "../core/migrateSave"; const SAVE_INTERVAL_MS = 30_000; // 30 seconds const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310"; @@ -51,9 +52,10 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO apiRequest("/save").then((data) => { if (data?.gameState) { - onLoad(data.gameState); + const migrated = migrateSave(data.gameState); + onLoad(migrated); lastSaveRef.current = data.lastSave; - console.info("[SaveSync] Loaded save from server — server is authority"); + console.info("[SaveSync] Loaded save from server — server is authority (v%d)", migrated.saveVersion); } else { console.info("[SaveSync] No server save found — starting fresh"); } @@ -99,7 +101,8 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO setTimeout(() => apiRequest("/save").then((data) => { if (data?.gameState && data.lastSave) { if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) { - onLoad(data.gameState); + const migrated = migrateSave(data.gameState); + onLoad(migrated); lastSaveRef.current = data.lastSave; console.info("[SaveSync] Reloaded from server on focus"); } diff --git a/Frontend/src/pages/Home.jsx b/Frontend/src/pages/Home.jsx index e93a96a..f4cb265 100755 --- a/Frontend/src/pages/Home.jsx +++ b/Frontend/src/pages/Home.jsx @@ -12,6 +12,8 @@ import { MilestoneBar } from "../components/MilestoneBar"; import { CockpitHeader } from "../components/CockpitHeader"; import { TadpoleSprite } from "../components/TadpoleSprite"; import { CosmeticsPanel } from "../components/CosmeticsPanel"; +import { PrestigeScreen } from "../components/PrestigeScreen"; +import { MilestonesPanel } from "../components/MilestonesPanel"; import { ACHIEVEMENTS } from "../data/achievements"; export default function Home() { @@ -136,6 +138,8 @@ export default function Home() { Clickerz — Tetard Universe + + {/* Clicker area — centre */}
@@ -152,6 +156,7 @@ export default function Home() {
+ diff --git a/Frontend/src/store/useGameStore.ts b/Frontend/src/store/useGameStore.ts index 8b4174f..c05f732 100644 --- a/Frontend/src/store/useGameStore.ts +++ b/Frontend/src/store/useGameStore.ts @@ -13,6 +13,10 @@ import { buyGenerator, buyEvolutionNode, resetEvolutionTree, + canResetTree, + upgradeConvergence, + canUpgradeConvergence, + claimMilestone as claimMilestoneFn, applyPrestige, canPrestige as canPrestigeCheck, totalProductionPerSecond, @@ -20,6 +24,7 @@ import { computeOfflineGains, offlineEfficiency, } from "../core/economy"; +import { migrateSave } from "../core/migrateSave"; import { computeNewUnlocks, equipCosmetic as equipCosmeticFn, @@ -36,11 +41,8 @@ function loadLocalState(): GameState { try { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; - const saved = JSON.parse(raw) as GameState; - // Backfill for old saves - if (!saved.lastOnline) saved.lastOnline = saved.lastTick; - if (!saved.cosmeticInventory) saved.cosmeticInventory = []; - if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {}; + const parsed = JSON.parse(raw); + const saved = migrateSave(parsed); return applyIdleGains(saved, Date.now()); } catch { return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; @@ -68,6 +70,11 @@ interface GameStore { offlineReport: OfflineReport | null; dismissOfflineReport: () => void; + // Prestige screen (modal fullscreen) + showPrestigeScreen: boolean; + openPrestigeScreen: () => void; + closePrestigeScreen: () => void; + // Last click result (for particle feedback) lastClickGain: number; lastClickDouble: boolean; @@ -84,6 +91,8 @@ interface GameStore { buyNode: (nodeId: string) => void; prestige: () => void; resetTree: () => void; + upgradeConvergenceNode: () => void; + claimMilestone: (milestoneId: string) => void; equipCosmetic: (cosmeticId: string) => void; unequipCosmetic: (slot: CosmeticSlot) => void; reset: () => void; @@ -94,11 +103,7 @@ interface GameStore { } function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } { - // Backfill for old saves - if (!saved.lastOnline) saved.lastOnline = saved.lastTick; - if (!saved.cosmeticInventory) saved.cosmeticInventory = []; - if (!saved.cosmeticEquipped) saved.cosmeticEquipped = {}; - + // migrateSave handles backfill — no manual patching needed here const elapsed = now - saved.lastTick; if (elapsed <= OFFLINE_THRESHOLD) { @@ -142,6 +147,9 @@ export const useGameStore = create((set, get) => ({ playSeconds: 0, ready: false, offlineReport: null, + showPrestigeScreen: false, + openPrestigeScreen: () => set({ showPrestigeScreen: true }), + closePrestigeScreen: () => set({ showPrestigeScreen: false }), lastClickGain: 0, lastClickDouble: false, lastClickCrit: false, @@ -238,6 +246,7 @@ export const useGameStore = create((set, get) => ({ state: updated, canPrestige: canPrestigeCheck(updated), productionPerSecond: totalProductionPerSecond(updated), + showPrestigeScreen: false, }; }); }, @@ -267,6 +276,7 @@ export const useGameStore = create((set, get) => ({ resetTree: () => { if (!get().ready) return; set((s) => { + if (!canResetTree(s.state)) return s; const updated = resetEvolutionTree(s.state); saveLocal(updated); return { @@ -276,6 +286,29 @@ export const useGameStore = create((set, get) => ({ }); }, + upgradeConvergenceNode: () => { + if (!get().ready) return; + set((s) => { + const updated = upgradeConvergence(s.state); + if (!updated) return s; + saveLocal(updated); + return { + state: updated, + productionPerSecond: totalProductionPerSecond(updated), + }; + }); + }, + + claimMilestone: (milestoneId: string) => { + if (!get().ready) return; + set((s) => { + const updated = claimMilestoneFn(s.state, milestoneId); + if (!updated) return s; + saveLocal(updated); + return { state: updated }; + }); + }, + reset: () => { const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; saveLocal(fresh); @@ -290,7 +323,8 @@ export const useGameStore = create((set, get) => ({ }, loadFromServer: (serverState: GameState) => { - const { state: hydrated, report } = hydrateWithOffline(serverState, Date.now()); + const migrated = migrateSave(serverState as unknown as Record); + const { state: hydrated, report } = hydrateWithOffline(migrated, Date.now()); saveLocal(hydrated); set({ state: hydrated, diff --git a/docs/GDD.md b/docs/GDD.md index c757538..c2af6d9 100644 --- a/docs/GDD.md +++ b/docs/GDD.md @@ -188,6 +188,43 @@ Brief technique : `docs/SPRINT2.md` --- +## Sprint 3 — Prestige Loop (endless) + +Brief technique : `docs/SPRINT3.md` + +| Feature | Design | +|---------|--------| +| Migration saves | Pattern `saveVersion` + `migrateSave()` — backward compat Sprint 2, lazy au chargement | +| Prestige Experience | Écran redesigné (preview ADN, stats run, comparaison), animation reset, hooks audio-ready | +| Arbre V2 endless | ~30 nœuds (3 branches approfondies), capstones game-changers, post-capstone repeatable (scaling par tranche ×1.5/×1.8/×2.0), cross-branche | +| Milestones prestige | 8 paliers (1→100 prestiges), cosmétiques exclusifs + bonus gameplay légers | +| Reset arbre | 1 gratuit par prestige, payant linéaire au-delà (5 ADN × n). Build exportable (fondation Sprint 4 sharing) | +| Formule ADN rebalancée | `max(1, floor(50 × log10(t/1e6) × (1 + min(0.05×p, 3.0))))` — clamp + cap bonus ×4 | + +### Capstones par branche + +| Branche | Capstone | Effet | +|---------|----------|-------| +| Ponte (click) | **Ponte Automatique** | Auto-click 1/sec, scale avec upgrades ponte | +| Marais (production) | **Symbiose Totale** | Chaque générateur booste les autres (+2% par type possédé) | +| Adaptation (utility) | **Mémoire du Marais** | Offline cap 25%→75%, durée 2h→8h | + +### Profils joueurs émergents + +- **Joueur Ponte** : joue activement, optimise les clics, capstone = auto-click idle +- **Joueur Marais** : optimise les achats générateurs, capstone = boucle multiplicative +- **Joueur Adaptation** : joue casual 2-3×/jour, capstone = idle puissant + +## Hors scope Sprint 3 + +- Boucle 3 (méta, events, leaderboard, cross-promo TetaRdPG) +- Son / musique (prévu Sprint 4+ — hooks audio posés dans prestige) +- Mobile responsive / client natif Godot (projet séparé envisagé) +- Monétisation effective (boutique cosmétique payante) +- Analytics joueur (event log backend — Sprint 4) + +--- + ## Changelog | Date | Changement | @@ -195,3 +232,5 @@ Brief technique : `docs/SPRINT2.md` | 2026-03-17 | GDD initial — sprint1-step1, stack React+TS+Vite, mécaniques core | | 2026-03-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express | | 2026-03-28 | Sprint 1 livré (6/6). Sprint 2 briefé — offline gains courbe inversée, arbre 3 voies, cosmétiques récompenses | +| 2026-03-28 | Sprint 2 livré (3/3). Sprint 3 briefé — prestige loop endless, arbre V2 30 nœuds, capstones, milestones, formule ADN rebalancée | +| 2026-03-28 | Sprint 3 brainstorm — 5 décisions : saveVersion migration, formule ADN clamp+cap, scaling post-capstone par tranche, reset 1 gratuit/prestige + vision build-sharing, Convergence évolutif (Alpha→Omega, nœud unique à tiers) | diff --git a/docs/SPRINT3.md b/docs/SPRINT3.md new file mode 100644 index 0000000..7f5132b --- /dev/null +++ b/docs/SPRINT3.md @@ -0,0 +1,315 @@ +# SPRINT3.md — Prestige Loop + +> Brief technique — Sprint 3 +> Date : 2026-03-28 +> Réf GDD : docs/GDD.md +> Dépend : Sprint 2 livré (3/3) +> Agents : game-designer (design) → build (implémentation) + +--- + +## Objectif + +Transformer le prestige d'une mécanique de reset en une **boucle de progression motivante et endless**. Le joueur doit sentir l'accélération à chaque génération, faire des choix de build durables, et viser des paliers qui récompensent la spécialisation. + +--- + +## Pré-requis technique — Migration saves + +> À traiter en amont de Step 1 — pas un step visible, mais bloquant. + +### Pattern `saveVersion` + `migrateSave()` (décision session 2026-03-28) + +Le GameState est stocké en JSON unique dans MySQL. Ajouter/modifier des champs +sans migration = `undefined` silencieux → NaN → bugs fantômes. + +**Mécanisme :** +- Ajouter `saveVersion: number` au `GameState` + - Saves Sprint 2 existantes = version absente → traitées comme `v1` + - Sprint 3 = `v2` +- Fonction `migrateSave(state: unknown): GameState` appliquée au chargement + (frontend `useSaveSync` + backend `saveControllers`) + - `v1 → v2` : injecter defaults (`runStats` vide, `treeResetCount: 0`, + `freeResetAvailable: true`, `repeatableNodes: {}`, nouveaux nœuds arbre) + - Les 18 nœuds Sprint 2 conservent leur `id` — les nouveaux s'ajoutent + - Champ critique manquant → log warning + default safe (jamais de crash) +- Chaque sprint futur ajoute un step de migration (`v2 → v3`, etc.) + +### Schéma DB + +- `ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1` +- Pas de migration du JSON existant en DB — la migration est lazy (au chargement) + +### Validation + +- Charger une save Sprint 2 brute sur le nouveau code → `migrateSave` injecte v2 +- Pas de perte de données, pas de reset forcé +- Les nœuds arbre existants (18) gardent leur position et état unlocked + +--- + +## Steps + +### Step 1 — Prestige Experience + +**Scope :** Rendre le moment du prestige satisfaisant et informatif. + +**Design :** +- Écran de prestige redesigné : + - Preview ADN gagné (avant de confirmer) + - Comparaison : "Run actuelle vs meilleure run" (durée, têtards, ADN) + - Compteur de générations (nombre total de prestiges) +- Statistiques de run persistées : + - Durée de la run + - Têtards produits (lifetime cette run) + - Vitesse de progression vs run précédente (% plus rapide / plus lent) + - Branche d'arbre principale utilisée +- Animation de reset : transition visuelle (le marais "renaît") +- Hooks audio-ready : prévoir les points d'ancrage pour le son (Sprint futur) + +**Technique :** +- `PrestigeScreen.tsx` : composant modal fullscreen (pas un simple bouton) +- `RunStats` dans le GameState : + ```ts + interface RunStats { + startedAt: number; + tadpolesProduced: number; + prestigeCount: number; + bestRun: { + duration: number; + tadpoles: number; + adn: number; + }; + } + ``` +- Backend : persister `runStats` dans la save (même pattern save serveur) +- Rebalancer la formule prestige pour l'endless (voir section Formules) + +**Critère done :** le joueur clique Prestige → voit un écran avec preview ADN + stats comparées → confirme → animation → nouvelle run. Les stats de la meilleure run sont persistées. + +--- + +### Step 2 — Arbre d'Évolution V2 — Profondeur endless + +**Scope :** Étendre l'arbre à ~30 nœuds avec capstones game-changers et scaling post-capstone infini. + +**Design — Structure par branche :** + +**Branche Ponte (click) — 8-10 nœuds :** +- Tier 1 : Ponte Améliorée (+100% click) — 1 ADN +- Tier 2 : Click Critique (5% chance ×10) — 5 ADN +- Tier 3 : Frénésie (click power +1% par click dans les 10 dernières sec, cap +50%) — 15 ADN +- Tier 3 alt (exclusif) : Concentration (+200% click, -25% idle) — 15 ADN +- Capstone : **Ponte Automatique** — auto-click 1/sec, scale avec upgrades ponte — 200 ADN +- Post-capstone (repeatable) : +5% auto-click speed, coût ×2 — départ 500 ADN + +**Branche Marais (production) — 8-10 nœuds :** +- Tier 1 : Instinct Grégaire (+50% production tous générateurs) — 3 ADN +- Tier 2 : Spécialisation (un générateur au choix ×3) — 8 ADN +- Tier 3 : Écosystème (+10% prod par type de générateur possédé) — 25 ADN +- Tier 3 alt (exclusif) : Monoculture (un seul type ×5, les autres ×0.5) — 25 ADN +- Capstone : **Symbiose Totale** — chaque générateur booste les autres (+2% par type possédé) — 300 ADN +- Post-capstone (repeatable) : +1% symbiose, coût ×2 — départ 600 ADN + +**Branche Adaptation (utility) — 8-10 nœuds :** +- Tier 1 : Mémoire Génétique (commence chaque run avec 100 têtards) — 2 ADN +- Tier 2 : Métabolisme Rapide (+25% offline cap) — 10 ADN +- Tier 3 : Héritage (conserve 5% des générateurs tier 1 entre prestiges) — 30 ADN +- Tier 3 alt (exclusif) : Mutation ADN (+25% ADN gagné au prestige) — 30 ADN +- Capstone : **Mémoire du Marais** — offline cap 25% → 75%, durée 2h → 8h — 250 ADN +- Post-capstone (repeatable) : +2% offline cap (au-delà de 75%), coût ×2 — départ 500 ADN + +**Nœud cross-branche — Convergence (évolutif, décision session 2026-03-28) :** + +Un seul nœud qui évolue quand le joueur atteint de nouveaux paliers de diversification. +Pattern `tier` sur le nœud — fondation pour d'autres nœuds évolutifs futurs. + +``` +Convergence Alpha (tier 1) + Condition : 1 capstone + tier 3 d'une 2e branche (~prestige 12-15) + Coût : 500 ADN + Effet : +10% à tous les effets de l'arbre + +Convergence Omega (tier 2 — même nœud, upgrade auto) + Condition : 2 capstones atteintes + Coût : 500 ADN supplémentaires (total investi : 1000) + Effet : +10% tous effets + -20% coût post-capstones +``` + +Technique — champ `tier` sur `EvolutionNode` : +```ts +tier?: number; // 1 = Alpha, 2 = Omega (current level) +maxTier?: number; // 2 +tierUpgradeCost?: number; // 500 +tierUpgradeCondition?: string; // "2_capstones" +``` + +UX : notification "Convergence a évolué !" quand la condition tier 2 est remplie. +Le joueur voit sa récompense grandir — pas un 2e nœud à acheter séparément. + +**Technique :** +- `DEFAULT_EVOLUTION_TREE` : ~30 nœuds (tree data structure, pas array) +- Ajouter `tier: number` et `repeatable: boolean` aux `EvolutionNode` +- `repeatableCount: number` dans le state pour les nœuds post-capstone +- UI : garder le layout 3 colonnes, ajouter scroll vertical par branche +- Capstones visuellement distincts (bordure dorée, icône spéciale) +- Cross-branche : section basse, verrouillée visuellement jusqu'aux conditions +- Convergence : badge évolutif (Alpha → Omega), indicateur de progression vers le prochain tier + +**Reset arbre — 1 gratuit par prestige (décision session 2026-03-28) :** + +Chaque prestige offre 1 reset gratuit (nouvelle génération = nouvelle chance de build). +Resets supplémentaires dans la même génération = payants. + +``` +freeResetAvailable: boolean // true après chaque prestige, false après usage +resetCostInGeneration: number = 5 × extraResetsUsed // linéaire, pas exponentiel +``` + +| Reset# (dans la même génération) | Coût ADN | +|----------------------------------|----------| +| 1er | Gratuit (offert par prestige) | +| 2e | 5 | +| 3e | 10 | +| 4e | 15 | + +Pas punitif, encourage l'expérimentation, mais décourage le spam. + +**Fondation build-sharing (Sprint 4+ vision) :** +Le build de l'arbre est exportable en string compacte (nœuds unlocked encodés). +Permet le partage de builds entre joueurs et les "runs prestige" communautaires. +→ Structurer l'arbre state pour faciliter l'export dès Sprint 3 : + `buildCode: string` = nœuds unlocked encodés base36 ou similaire. + +**Critère done :** l'arbre affiche ~30 nœuds sur 3 branches + cross-branche, les capstones changent le gameplay de manière perceptible, les post-capstones sont achetables en boucle. + +--- + +### Step 3 — Milestones de Prestige + +**Scope :** Récompenser la persévérance avec des paliers de prestige qui débloquent cosmétiques exclusifs et bonus légers. + +**Design :** + +| Palier | Récompense | Type | +|--------|------------|------| +| 1 prestige | Badge "Première Génération" + ruban queue | cosmétique | +| 3 prestiges | Titre "Gardien Récurrent" | cosmétique | +| 5 prestiges | Start avec 1 Nid gratuit | gameplay léger | +| 10 prestiges | Skin "Têtard Ancestral" (body prestige) + couronne dorée | cosmétique | +| 15 prestiges | +5% offline cap permanent | gameplay léger | +| 25 prestiges | Cape d'algues ancestrales + aura prestige | cosmétique | +| 50 prestiges | Titre "Légende du Marais" + particules dorées permanentes | cosmétique | +| 100 prestiges | Skin "Têtard Primordial" (full set) | cosmétique | + +- Les bonus gameplay sont **légers** — jamais assez pour casser l'économie +- Les cosmétiques prestige-only ne sont pas obtenables autrement (exclusivité = motivation) +- Écran milestones accessible depuis le menu prestige (progress bar vers le prochain palier) + +**Technique :** +- `PrestigeMilestone` type : + ```ts + interface PrestigeMilestone { + id: string; + threshold: number; // nombre de prestiges requis + reward: MilestoneReward; + claimed: boolean; + } + + type MilestoneReward = + | { type: "cosmetic"; cosmeticId: string } + | { type: "bonus"; effect: EffectType; value: number } + | { type: "title"; title: string }; + ``` +- Intégrer avec le système cosmétiques V1 existant — ajouter `source: "prestige_milestone"` aux cosmétiques +- UI : `MilestonesPanel.tsx` — liste verticale avec progress bar, claim button, preview reward +- Backend : valider les claims (le joueur ne peut pas claim un milestone sans le nombre de prestiges) + +**Critère done :** atteindre 5 prestiges → notification milestone → claim → Nid gratuit actif + cosmétique dans l'inventaire. + +--- + +## Formules — Rebalancing endless (décisions session 2026-03-28) + +### Formule ADN (remplace l'actuelle) + +Actuelle : `adn = floor(150 × sqrt(lifetime_tadpoles / 1e9))` — s'aplatit trop vite. + +Nouvelle : +``` +adn = max(1, floor(base × log10(tadpoles / threshold) × (1 + bonus))) + + base = 50 + threshold = 1e6 (1M têtards — seuil minimum pour prestige) + bonus = min(0.05 × prestigeCount, 3.0) // cap ×4 max à 80 prestiges + clamp = minimum 1 ADN si tadpoles >= threshold +``` + +| Run | Tadpoles | Prestige# | Bonus | ADN gagné | +|-----|----------|-----------|-------|-----------| +| 1 | 1M (seuil) | 0 | ×1.0 | **1** (clamp) | +| 1 | 10M | 0 | ×1.0 | **50** | +| 5 | 100M | 4 | ×1.20 | **120** | +| 15 | 1B | 14 | ×1.70 | **255** | +| 30 | 10B | 29 | ×2.45 | **430** | +| 80+ | 100B | 80 | ×4.00 | **800** (cap bonus atteint) | + +La courbe log scale bien : récompense toujours plus, oblige à aller exponentiellement plus loin. +Le cap du bonus évite que l'endgame trivialise l'arbre. + +### Courbe coût post-capstones — paliers par tranche + +Le ×2 brut crée un mur après ~8 achats. Scaling par tranche : + +``` +Achats 1-5 : cost = base × 1.5^n +Achats 6-10 : cost = base × 1.5^5 × 1.8^(n-5) +Achats 11+ : cost = base × 1.5^5 × 1.8^5 × 2.0^(n-10) +``` + +| Achat# | Coût (base 500) | ~Runs nécessaires (400 ADN/run) | +|--------|-----------------|--------------------------------| +| 1 | 750 | ~2 | +| 5 | 3 797 | ~10 | +| 10 | 68 890 | ~172 | +| 15 | 2.2M | endgame long | + +Chaque achat reste "quelques sessions" en mid-game, puis ralentit en endgame +sans frapper un mur infranchissable. + +### Courbe coût nœuds standards + +Définis manuellement par nœud (1, 3, 5, 8, 10, 15, 25, 30, 200, 250, 300, 500, 1000 ADN). + +### Vérification d'équilibre + +- Premier capstone : entre le 8e et 12e prestige (spécialisation une branche) +- Arbre "complet" (hors post-capstone) : ~15-20 prestiges +- Post-capstones : progression infinie, chaque achat = investissement plus lourd mais gain marginal +- Cross-branche : joueur dédié à 25+ prestiges + +**Niveau de confiance : moyen** — les chiffres sont calibrés sur les formules mais devront être playtestés. Toutes les constantes centralisées dans `balance.ts` pour ajustement rapide. + +--- + +## Résumé séquentiel + +``` +Migration saves → Step 1 (prestige experience) → Step 2 (arbre V2 endless) → Step 3 (milestones) +``` + +Step 1 = le moment. Step 2 = la profondeur. Step 3 = la motivation long terme. + +--- + +## Risques identifiés + +| Risque | Mitigation | Statut | +|--------|------------|--------| +| Formule ADN mal calibrée | Constantes dans `balance.ts`. Clamp min 1 + cap bonus ×4. Playtest après Step 1 | ✅ adressé | +| Capstones trop puissants (Ponte Auto trivialise) | Tester chaque capstone isolément. Auto-click = 1/sec de base | à tester | +| Post-capstone wall en late-game | Scaling par tranche (×1.5/×1.8/×2.0) au lieu de ×2 brut | ✅ adressé | +| Migration saves casse les joueurs existants | Pattern `saveVersion` + `migrateSave()` lazy. Saves Sprint 2 = v1, migrées automatiquement | ✅ adressé | +| Reset arbre frustrant | 1 gratuit par prestige + linéaire au-delà. Pas d'exponentiel | ✅ adressé | +| Arbre V2 complexe visuellement | Progressive disclosure : griser les nœuds non débloquables, tooltip clair | à tester | +| Build-sharing string : encodage fragile | Valider le décodage côté client. Nœud inconnu dans le code = ignoré (forward compat) | Sprint 4 |