diff --git a/Frontend/src/__tests__/economy.test.ts b/Frontend/src/__tests__/economy.test.ts index 9eea36d..4a36a91 100644 --- a/Frontend/src/__tests__/economy.test.ts +++ b/Frontend/src/__tests__/economy.test.ts @@ -17,6 +17,7 @@ import { getStartBonusFromTree, getPrestigeDnaBonus, getCostReduction, + getAutoClicksPerSecond, offlineEfficiency, computeOfflineGains, DEFAULT_STATE, @@ -173,13 +174,15 @@ describe("computeIdleGains (lazy calculation)", () => { }); }); -// --- Click --- +// --- Click (avec double + crit) --- describe("applyClick", () => { it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => { const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 }; - const result = applyClick(state); - expect(result.resources).toBe(6); + const result = applyClick(state, 0.99); // rng high → no double, no crit + expect(result.state.resources).toBe(6); + expect(result.isDouble).toBe(false); + expect(result.isCrit).toBe(false); }); it("applique le multiplicateur click de l'arbre", () => { @@ -191,14 +194,58 @@ describe("applyClick", () => { n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n ), }; - const result = applyClick(state); - expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée + const result = applyClick(state, 0.99); + expect(result.state.resources).toBe(2); }); it("incrémente lifetimeTadpoles", () => { const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 }; - const result = applyClick(state); - expect(result.lifetimeTadpoles).toBe(5); + const result = applyClick(state, 0.99); + expect(result.state.lifetimeTadpoles).toBe(5); + }); + + it("double ponte x2 quand rng < doubleClickChance", () => { + const state = { + ...DEFAULT_STATE, + clickMultiplier: 1, + prestigeMultiplier: 1, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "double_ponte" ? { ...n, unlocked: true } : n + ), + }; + // double_ponte = 10% chance, rng=0.05 < 0.10 → double + const result = applyClick(state, 0.05); + expect(result.isDouble).toBe(true); + expect(result.gain).toBe(2); + }); + + it("pas de double ponte quand rng > doubleClickChance", () => { + const state = { + ...DEFAULT_STATE, + clickMultiplier: 1, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "double_ponte" ? { ...n, unlocked: true } : n + ), + }; + const result = applyClick(state, 0.50); + expect(result.isDouble).toBe(false); + expect(result.gain).toBe(1); + }); + + it("crit x10 quand critRng < critClickChance", () => { + const state = { + ...DEFAULT_STATE, + clickMultiplier: 1, + prestigeMultiplier: 1, + evolutionTree: DEFAULT_STATE.evolutionTree.map((n) => + n.id === "ponte_critique" ? { ...n, unlocked: true } : n + ), + }; + // ponte_critique = 5% chance, need critRng = (rng * 7.13) % 1 < 0.05 + // rng = 0.007 → critRng = 0.04991 < 0.05 → crit! + const result = applyClick(state, 0.007); + expect(result.isCrit).toBe(true); + expect(result.gain).toBe(10); }); }); @@ -444,6 +491,46 @@ 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", () => { + const state = { + ...DEFAULT_STATE, + resources: 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); + }); + }); + + describe("auto_click (getAutoClicksPerSecond)", () => { + it("retourne 0 si auto_ponte non débloqué", () => { + expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0); + }); + + it("retourne 1 si auto_ponte débloqué", () => { + const tree = DEFAULT_EVOLUTION_TREE.map((n) => + n.id === "auto_ponte" ? { ...n, unlocked: true } : n + ); + expect(getAutoClicksPerSecond(tree)).toBe(1); + }); + }); + describe("prestige threshold reduction", () => { it("Transcendance réduit le seuil de 50%", () => { const state = { diff --git a/Frontend/src/components/EvolutionTree.tsx b/Frontend/src/components/EvolutionTree.tsx index 5451ac2..a721fcf 100644 --- a/Frontend/src/components/EvolutionTree.tsx +++ b/Frontend/src/components/EvolutionTree.tsx @@ -9,7 +9,6 @@ const EFFECT_LABELS: Record string> = { production_multiplier: (v) => `x${v} production`, start_bonus: (v) => `+${v} têtards au départ`, unlock_generator: () => `Lac Mystique dès le début`, - achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`, double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`, auto_click: (v) => `${v} auto-ponte/s`, crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`, diff --git a/Frontend/src/core/economy.ts b/Frontend/src/core/economy.ts index 4403c05..8f706e3 100644 --- a/Frontend/src/core/economy.ts +++ b/Frontend/src/core/economy.ts @@ -14,7 +14,6 @@ export type EffectType = | "production_multiplier" | "start_bonus" | "unlock_generator" - | "achievement_scaling" | "double_click_chance" | "auto_click" | "crit_click_chance" @@ -65,7 +64,7 @@ export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [ { 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: "ponte_frenetique", branch: "ponte" }, + { 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" }, // --- Marais (production) --- @@ -305,19 +304,48 @@ export function applyIdleGains(state: GameState, now: number): GameState { }; } -// Gain réel par clic (pour affichage particule) +// Gain de base par clic (sans RNG — pour affichage tooltip) export function getClickGain(state: GameState): number { const treeClickMult = getClickMultiplierFromTree(state.evolutionTree); return state.clickMultiplier * state.prestigeMultiplier * treeClickMult; } -// Clic manuel -export function applyClick(state: GameState): GameState { - const gain = getClickGain(state); +export interface ClickResult { + state: GameState; + gain: number; + isDouble: boolean; + isCrit: boolean; +} + +// Clic manuel avec double ponte + crit +export function applyClick(state: GameState, rng: number = Math.random()): ClickResult { + let gain = getClickGain(state); + let isDouble = false; + let isCrit = false; + + const doubleChance = getDoubleClickChance(state.evolutionTree); + if (doubleChance > 0 && rng < doubleChance) { + gain *= 2; + isDouble = true; + } + + const critChance = getCritClickChance(state.evolutionTree); + // Use a second "roll" derived from rng to avoid double+crit being correlated + const critRng = (rng * 7.13) % 1; + if (critChance > 0 && critRng < critChance) { + gain *= 10; + isCrit = true; + } + return { - ...state, - resources: state.resources + gain, - lifetimeTadpoles: state.lifetimeTadpoles + gain, + state: { + ...state, + resources: state.resources + gain, + lifetimeTadpoles: state.lifetimeTadpoles + gain, + }, + gain, + isDouble, + isCrit, }; } @@ -359,10 +387,18 @@ export function applyPrestige(state: GameState): GameState { const dnaGained = Math.floor(baseDna * (1 + dnaBonus)); const startBonus = getStartBonusFromTree(state.evolutionTree); + // Résilience : commencer avec 1 Lac Mystique + const hasUnlockGen = state.evolutionTree.some( + (n) => n.unlocked && n.effect === "unlock_generator" + ); + return { ...state, resources: startBonus, - generators: state.generators.map((g) => ({ ...g, owned: 0 })), + generators: state.generators.map((g) => ({ + ...g, + owned: hasUnlockGen && g.id === "lac" ? 1 : 0, + })), prestigeCount: newPrestigeCount, prestigeMultiplier: 1 + newPrestigeCount * 0.1, ancestralDna: state.ancestralDna + dnaGained, diff --git a/Frontend/src/data/achievements.ts b/Frontend/src/data/achievements.ts index 04a5e1b..3c6d105 100644 --- a/Frontend/src/data/achievements.ts +++ b/Frontend/src/data/achievements.ts @@ -165,9 +165,12 @@ export const ACHIEVEMENTS: Achievement[] = [ { id: "full_tree", name: "Évolution Complète", - description: "Débloquer tous les noeuds de l'arbre.", + description: "Débloquer un nœud dans chaque branche de l'arbre.", icon: "🌳", - check: (s) => s.evolutionTree.every((n) => n.unlocked), + check: (s) => { + const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch)); + return branches.size >= 3; + }, }, // --- Easter eggs & humour --- @@ -211,6 +214,6 @@ export const ACHIEVEMENTS: Achievement[] = [ name: "Le Cercle de la Vie", description: "Symbiose activée. Même Mufasa serait fier.", icon: "🦁", - check: (s) => hasEvolutionNode(s, "symbiose"), + check: (s) => hasEvolutionNode(s, "symbiose_algale"), }, ]; diff --git a/Frontend/src/pages/Home.jsx b/Frontend/src/pages/Home.jsx index 5aeb171..e93a96a 100755 --- a/Frontend/src/pages/Home.jsx +++ b/Frontend/src/pages/Home.jsx @@ -22,21 +22,30 @@ export default function Home() { const state = useGameStore((s) => s.state); const clickGain = getClickGain(state); - const createParticle = useCallback((clientX, clientY) => { + const lastClickGain = useGameStore((s) => s.lastClickGain); + const lastClickDouble = useGameStore((s) => s.lastClickDouble); + const lastClickCrit = useGameStore((s) => s.lastClickCrit); + + const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => { const particle = document.createElement("span"); particle.className = "click-particle"; - particle.textContent = `+${formatNumber(clickGain)}`; + const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : ""; + particle.textContent = `${prefix}+${formatNumber(gain)}`; + if (isCrit) particle.style.color = "#f59e0b"; + else if (isDouble) particle.style.color = "#a78bfa"; particle.style.left = `${clientX}px`; particle.style.top = `${clientY}px`; document.body.appendChild(particle); setTimeout(() => { if (particle.parentNode) particle.parentNode.removeChild(particle); }, 800); - }, [clickGain]); + }, []); const handleIncrement = useCallback((e) => { click(); - createParticle(e.clientX, e.clientY); + // Read latest click result from store after click + const s = useGameStore.getState(); + createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit); }, [click, createParticle]); // Rain effect (ambiance) diff --git a/Frontend/src/store/useGameStore.ts b/Frontend/src/store/useGameStore.ts index a3381ff..8b4174f 100644 --- a/Frontend/src/store/useGameStore.ts +++ b/Frontend/src/store/useGameStore.ts @@ -8,6 +8,8 @@ import { DEFAULT_STATE, applyIdleGains, applyClick, + getClickGain, + getAutoClicksPerSecond, buyGenerator, buyEvolutionNode, resetEvolutionTree, @@ -66,6 +68,11 @@ interface GameStore { offlineReport: OfflineReport | null; dismissOfflineReport: () => void; + // Last click result (for particle feedback) + lastClickGain: number; + lastClickDouble: boolean; + lastClickCrit: boolean; + // Derived (recalculated on tick) canPrestige: boolean; productionPerSecond: number; @@ -135,6 +142,9 @@ export const useGameStore = create((set, get) => ({ playSeconds: 0, ready: false, offlineReport: null, + lastClickGain: 0, + lastClickDouble: false, + lastClickCrit: false, canPrestige: false, productionPerSecond: 0, @@ -147,6 +157,14 @@ export const useGameStore = create((set, get) => ({ const updated = applyIdleGains(s.state, now); updated.lastOnline = now; + // Auto-click from evolution tree + const autoClicks = getAutoClicksPerSecond(updated.evolutionTree); + if (autoClicks > 0) { + const autoGain = getClickGain(updated) * autoClicks; + updated.resources += autoGain; + updated.lifetimeTadpoles += autoGain; + } + // Check cosmetic unlocks every 5s if (s.playSeconds % 5 === 0) { const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped }; @@ -170,12 +188,15 @@ export const useGameStore = create((set, get) => ({ click: () => { if (!get().ready) return; set((s) => { - const updated = applyClick(applyIdleGains(s.state, Date.now())); - saveLocal(updated); + const result = applyClick(applyIdleGains(s.state, Date.now())); + saveLocal(result.state); return { - state: updated, - canPrestige: canPrestigeCheck(updated), - productionPerSecond: totalProductionPerSecond(updated), + state: result.state, + lastClickGain: result.gain, + lastClickDouble: result.isDouble, + lastClickCrit: result.isCrit, + canPrestige: canPrestigeCheck(result.state), + productionPerSecond: totalProductionPerSecond(result.state), }; }); },