feat: arbre d'évolution 3 voies — ponte/marais/adaptation

18 nœuds (6/branche), nœuds exclusifs (pick one), reset gratuit.
Nouveaux effets : double_click, auto_click, crit, generator_boost,
cost_reduction, prestige_dna_bonus, offline_boost, threshold_reduction.
UI 3 colonnes colorées, seuil prestige dynamique, coût réduit.
75 tests (tous passent).
This commit is contained in:
2026-03-28 11:52:51 +01:00
parent 3ba10dad5f
commit ae50908bc9
7 changed files with 405 additions and 76 deletions

View File

@@ -7,12 +7,16 @@ import {
buyGenerator, buyGenerator,
applyPrestige, applyPrestige,
canPrestige, canPrestige,
getPrestigeThreshold,
computePrestigeDna, computePrestigeDna,
canBuyEvolutionNode, canBuyEvolutionNode,
buyEvolutionNode, buyEvolutionNode,
resetEvolutionTree,
getClickMultiplierFromTree, getClickMultiplierFromTree,
getProductionMultiplierFromTree, getProductionMultiplierFromTree,
getStartBonusFromTree, getStartBonusFromTree,
getPrestigeDnaBonus,
getCostReduction,
offlineEfficiency, offlineEfficiency,
computeOfflineGains, computeOfflineGains,
DEFAULT_STATE, DEFAULT_STATE,
@@ -263,34 +267,33 @@ describe("computePrestigeDna", () => {
}); });
}); });
// --- Arbre d'Évolution --- // --- Arbre d'Évolution 3 voies ---
describe("Evolution Tree (3 branches)", () => {
it("arbre a 18 nœuds répartis en 3 branches", () => {
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);
});
describe("Evolution Tree", () => {
describe("canBuyEvolutionNode", () => { describe("canBuyEvolutionNode", () => {
it("peut acheter le premier nœud (pas de prérequis) avec assez d'ADN", () => { it("peut acheter un nœud racine avec assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 }; const state = { ...DEFAULT_STATE, ancestralDna: 5 };
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true); expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true);
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true);
expect(canBuyEvolutionNode(state, "memoire_genetique")).toBe(true);
}); });
it("ne peut pas acheter sans assez d'ADN", () => { it("ne peut pas acheter sans assez d'ADN", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 0 }; expect(canBuyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBe(false);
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
});
it("ne peut pas acheter un nœud déjà débloqué", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
}); });
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => { it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 100 }; const state = { ...DEFAULT_STATE, ancestralDna: 100 };
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(false); expect(canBuyEvolutionNode(state, "double_ponte")).toBe(false);
}); });
it("peut acheter un nœud si le prérequis est débloqué", () => { it("peut acheter un nœud si le prérequis est débloqué", () => {
@@ -301,7 +304,32 @@ describe("Evolution Tree", () => {
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
), ),
}; };
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true); expect(canBuyEvolutionNode(state, "double_ponte")).toBe(true);
});
it("ne peut pas acheter un nœud exclusif si l'alternative est débloquée", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } :
n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
),
};
// auto_ponte exclusive_with ponte_frenetique → locked
expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(false);
});
it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => {
const state = {
...DEFAULT_STATE,
ancestralDna: 100,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
),
};
expect(canBuyEvolutionNode(state, "auto_ponte")).toBe(true);
expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true);
}); });
}); });
@@ -315,15 +343,30 @@ describe("Evolution Tree", () => {
}); });
it("retourne null si impossible", () => { it("retourne null si impossible", () => {
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree"); expect(buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBeNull();
expect(result).toBeNull(); });
}); });
it("ne modifie pas les autres nœuds", () => { describe("resetEvolutionTree", () => {
const state = { ...DEFAULT_STATE, ancestralDna: 5 }; it("rembourse tout l'ADN dépensé et relock tous les nœuds", () => {
const result = buyEvolutionNode(state, "ponte_amelioree")!; const state = {
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree"); ...DEFAULT_STATE,
expect(otherNodes.every((n) => n.unlocked === false)).toBe(true); ancestralDna: 50,
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
const result = resetEvolutionTree(state);
expect(result.ancestralDna).toBe(52);
expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true);
});
it("ne change rien si aucun nœud débloqué", () => {
const result = resetEvolutionTree({ ...DEFAULT_STATE, ancestralDna: 10 });
expect(result.ancestralDna).toBe(10);
}); });
}); });
@@ -338,6 +381,13 @@ describe("Evolution Tree", () => {
); );
expect(getClickMultiplierFromTree(tree)).toBe(2); expect(getClickMultiplierFromTree(tree)).toBe(2);
}); });
it("multiplie si plusieurs nœuds click débloqués (2 × 3 = 6)", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "ponte_amelioree" || n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
);
expect(getClickMultiplierFromTree(tree)).toBe(6);
});
}); });
describe("getProductionMultiplierFromTree", () => { describe("getProductionMultiplierFromTree", () => {
@@ -365,6 +415,57 @@ describe("Evolution Tree", () => {
expect(getStartBonusFromTree(tree)).toBe(100); expect(getStartBonusFromTree(tree)).toBe(100);
}); });
}); });
describe("prestige_dna_bonus", () => {
it("ADN Renforcé + Héritage = +75% ADN", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "adn_renforce" || n.id === "heritage" ? { ...n, unlocked: true } : n
);
expect(getPrestigeDnaBonus(tree)).toBeCloseTo(0.75);
});
});
describe("cost_reduction", () => {
it("Marée Haute = -20% coût générateurs", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "maree_haute" ? { ...n, unlocked: true } : n
);
expect(getCostReduction(tree)).toBeCloseTo(0.20);
});
it("coût réduit appliqué via generatorCost", () => {
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
n.id === "maree_haute" ? { ...n, unlocked: true } : n
);
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
const baseCost = generatorCost(gen);
const reducedCost = generatorCost(gen, tree);
expect(reducedCost).toBe(Math.floor(baseCost * 0.8));
});
});
describe("prestige threshold reduction", () => {
it("Transcendance réduit le seuil de 50%", () => {
const state = {
...DEFAULT_STATE,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "transcendance" ? { ...n, unlocked: true } : n
),
};
expect(getPrestigeThreshold(state)).toBe(500_000);
});
it("canPrestige utilise le seuil réduit", () => {
const state = {
...DEFAULT_STATE,
resources: 600_000,
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
n.id === "transcendance" ? { ...n, unlocked: true } : n
),
};
expect(canPrestige(state)).toBe(true);
});
});
}); });
// --- Offline gains (courbe inversée) --- // --- Offline gains (courbe inversée) ---

View File

@@ -1,8 +1,8 @@
// EvolutionTree.tsx — Arbre d'Évolution permanent (jamais reset) // EvolutionTree.tsx — Arbre d'Évolution 3 voies (permanent jamais reset par prestige)
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { canBuyEvolutionNode } from "../core/economy"; import { canBuyEvolutionNode, getSpentDna } from "../core/economy";
import type { EvolutionNode } from "../core/economy"; import type { EvolutionNode, Branch } from "../core/economy";
const EFFECT_LABELS: Record<string, (v: number) => string> = { const EFFECT_LABELS: Record<string, (v: number) => string> = {
click_multiplier: (v) => `x${v} ponte`, click_multiplier: (v) => `x${v} ponte`,
@@ -10,19 +10,37 @@ const EFFECT_LABELS: Record<string, (v: number) => string> = {
start_bonus: (v) => `+${v} têtards au départ`, start_bonus: (v) => `+${v} têtards au départ`,
unlock_generator: () => `Lac Mystique dès le début`, unlock_generator: () => `Lac Mystique dès le début`,
achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`, 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`,
generator_boost: (v) => `x${v} Nid`,
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% coût générateurs`,
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`,
};
const BRANCH_CONFIG: Record<Branch, { label: string; color: string; accent: string }> = {
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" },
}; };
function NodeRow({ function NodeRow({
node, node,
canBuy, canBuy,
isExcluded,
onBuy, onBuy,
}: { }: {
node: EvolutionNode; node: EvolutionNode;
canBuy: boolean; canBuy: boolean;
isExcluded: boolean;
onBuy: () => void; onBuy: () => void;
}) { }) {
const rowClass = node.unlocked const rowClass = node.unlocked
? "gp-row gp-row--unlocked" ? "gp-row gp-row--unlocked"
: isExcluded
? "gp-row gp-row--locked opacity-30!"
: canBuy : canBuy
? "gp-row gp-row--evolution" ? "gp-row gp-row--evolution"
: "gp-row gp-row--locked"; : "gp-row gp-row--locked";
@@ -30,45 +48,101 @@ function NodeRow({
return ( return (
<div className={rowClass}> <div className={rowClass}>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="gp-value">{node.name}</span> <div className="flex items-center gap-1">
<span className="gp-label">{EFFECT_LABELS[node.effect](node.value)}</span> <span className="gp-value text-[0.7rem]!">{node.name}</span>
{node.exclusive_with && !node.unlocked && !isExcluded && (
<span className="gp-label text-[0.55rem]!">OU</span>
)}
</div>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value) ?? node.effect}</span>
</div> </div>
{node.unlocked ? ( {node.unlocked ? (
<span className="gp-label gp-accent-green">OK</span> <span className="gp-label gp-accent-green">OK</span>
) : isExcluded ? (
<span className="gp-label text-[0.55rem]!">verrouillé</span>
) : ( ) : (
<button <button
disabled={!canBuy} disabled={!canBuy}
onClick={onBuy} onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy bg-amber-600!" : "gp-btn--disabled"}`} className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
> >
{node.cost} ADN {node.cost}
</button> </button>
)} )}
</div> </div>
); );
} }
export function EvolutionTree() { function BranchColumn({ branch }: { branch: Branch }) {
const state = useGameStore((s) => s.state); const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode); const buyNode = useGameStore((s) => s.buyNode);
const { evolutionTree, prestigeCount } = state; const nodes = state.evolutionTree.filter((n) => n.branch === branch);
const config = BRANCH_CONFIG[branch];
if (prestigeCount < 1) return null;
return ( return (
<div className="gp"> <div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
<div className="flex justify-between items-center"> <span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
<span className="gp-title">Évolution</span> {nodes.map((node) => {
<span className="gp-value gp-accent-amber">{state.ancestralDna} ADN</span> const isExcluded = node.exclusive_with
</div> ? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
{evolutionTree.map((node) => ( : false;
return (
<NodeRow <NodeRow
key={node.id} key={node.id}
node={node} node={node}
canBuy={canBuyEvolutionNode(state, node.id)} canBuy={canBuyEvolutionNode(state, node.id)}
isExcluded={isExcluded}
onBuy={() => buyNode(node.id)} onBuy={() => buyNode(node.id)}
/> />
))} );
})}
</div>
);
}
export function EvolutionTree() {
const state = useGameStore((s) => s.state);
const resetTree = useGameStore((s) => s.resetTree);
const { prestigeCount, ancestralDna, evolutionTree } = state;
if (prestigeCount < 1) return null;
const spentDna = getSpentDna(evolutionTree);
const hasUnlocked = spentDna > 0;
const handleReset = () => {
if (!hasUnlocked) return;
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` +
`Confirmer ?`
);
if (confirmed) resetTree();
};
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center px-1">
<span className="gp-title">Évolution</span>
<div className="flex items-center gap-2">
<span className="gp-value gp-accent-amber">{ancestralDna} ADN</span>
{hasUnlocked && (
<button
onClick={handleReset}
className="gp-btn gp-btn--disabled text-[0.55rem]! hover:bg-red-500/20! hover:text-red-400!"
title={`Récupérer ${spentDna} ADN`}
>
Reset
</button>
)}
</div>
</div>
<div className="flex gap-1.5">
<BranchColumn branch="ponte" />
<BranchColumn branch="marais" />
<BranchColumn branch="adaptation" />
</div>
</div> </div>
); );
} }

View File

@@ -8,7 +8,7 @@ export function GeneratorShop() {
const resources = useGameStore((s) => s.state.resources); const resources = useGameStore((s) => s.state.resources);
const productionPerSecond = useGameStore((s) => s.productionPerSecond); const productionPerSecond = useGameStore((s) => s.productionPerSecond);
const buy = useGameStore((s) => s.buy); const buy = useGameStore((s) => s.buy);
const generatorCost = useGameStore((s) => s.generatorCost); const generatorCost = useGameStore((s) => s.generatorCostWithTree);
return ( return (
<div className="gp"> <div className="gp">

View File

@@ -1,23 +1,24 @@
// MilestoneBar.tsx — Progression vers le prochain prestige // MilestoneBar.tsx — Progression vers le prochain prestige
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { formatNumber } from "../utils/formatNumber"; import { formatNumber, } from "../utils/formatNumber";
import { getPrestigeThreshold } from "../core/economy";
const PRESTIGE_THRESHOLD = 1_000_000;
export function MilestoneBar() { export function MilestoneBar() {
const resources = useGameStore((s) => s.state.resources); const state = useGameStore((s) => s.state);
const resources = state.resources;
const threshold = getPrestigeThreshold(state);
const progress = Math.min(resources / PRESTIGE_THRESHOLD, 1); const progress = Math.min(resources / threshold, 1);
const progressPercent = (progress * 100).toFixed(1); const progressPercent = (progress * 100).toFixed(1);
const remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0); const remaining = Math.max(threshold - resources, 0);
return ( return (
<div className="gp gap-1"> <div className="gp gap-1">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="gp-label">Prochaine Génération</span> <span className="gp-label">Prochaine Génération</span>
<span className="gp-label"> <span className="gp-label">
{formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)} {formatNumber(resources)} / {formatNumber(threshold)}
</span> </span>
</div> </div>
<div className="gp-progress"> <div className="gp-progress">

View File

@@ -1,14 +1,19 @@
// PrestigePanel.tsx — Nouvelle Génération (prestige) // PrestigePanel.tsx — Nouvelle Génération (prestige)
import { useGameStore } from "../store/useGameStore"; import { useGameStore } from "../store/useGameStore";
import { computePrestigeDna } from "../core/economy"; import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
export function PrestigePanel() { export function PrestigePanel() {
const { lifetimeTadpoles } = useGameStore((s) => s.state); const { lifetimeTadpoles } = useGameStore((s) => s.state);
const canPrestige = useGameStore((s) => s.canPrestige); const canPrestige = useGameStore((s) => s.canPrestige);
const prestige = useGameStore((s) => s.prestige); const prestige = useGameStore((s) => s.prestige);
const dnaPreview = computePrestigeDna(lifetimeTadpoles); const state = useGameStore((s) => s.state);
const baseDna = computePrestigeDna(lifetimeTadpoles);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
const handlePrestige = () => { const handlePrestige = () => {
const confirmed = window.confirm( const confirmed = window.confirm(
@@ -35,7 +40,7 @@ export function PrestigePanel() {
</button> </button>
</div> </div>
) : ( ) : (
<span className="gp-label">Atteins 1M têtards pour prestige</span> <span className="gp-label">Atteins {formatNumber(threshold)} têtards pour prestige</span>
)} )}
</div> </div>
); );

View File

@@ -9,7 +9,22 @@ export interface Generator {
owned: number; owned: number;
} }
export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling"; export type EffectType =
| "click_multiplier"
| "production_multiplier"
| "start_bonus"
| "unlock_generator"
| "achievement_scaling"
| "double_click_chance"
| "auto_click"
| "crit_click_chance"
| "generator_boost"
| "cost_reduction"
| "prestige_dna_bonus"
| "offline_boost"
| "prestige_threshold_reduction";
export type Branch = "ponte" | "marais" | "adaptation";
export interface EvolutionNode { export interface EvolutionNode {
id: string; id: string;
@@ -19,6 +34,8 @@ export interface EvolutionNode {
value: number; value: number;
unlocked: boolean; unlocked: boolean;
requires: string | null; // id du nœud prérequis (null = racine) requires: string | null; // id du nœud prérequis (null = racine)
branch: Branch;
exclusive_with?: string; // id du nœud alternatif (pick one)
} }
export interface GameState { export interface GameState {
@@ -37,11 +54,29 @@ export interface GameState {
// --- Arbre d'Évolution --- // --- Arbre d'Évolution ---
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [ export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null }, // --- Ponte (click) ---
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: "ponte_amelioree" }, { id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 10, effect: "start_bonus", value: 100, unlocked: false, requires: "instinct_gregaire" }, { id: "double_ponte", name: "Double Ponte", cost: 3, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" }, { 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: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" }, { 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: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
// --- 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" },
// --- 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" },
]; ];
// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9)) // Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9))
@@ -58,6 +93,11 @@ export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
const prereq = state.evolutionTree.find((n) => n.id === node.requires); const prereq = state.evolutionTree.find((n) => n.id === node.requires);
if (!prereq || !prereq.unlocked) return false; if (!prereq || !prereq.unlocked) return false;
} }
// Exclusive node: can't buy if the alternative is already unlocked
if (node.exclusive_with) {
const alt = state.evolutionTree.find((n) => n.id === node.exclusive_with);
if (alt && alt.unlocked) return false;
}
return true; return true;
} }
@@ -75,6 +115,24 @@ export function buyEvolutionNode(state: GameState, nodeId: string): GameState |
}; };
} }
// Reset l'arbre — rembourse tout l'ADN dépensé, relock tous les nœuds
export function resetEvolutionTree(state: GameState): GameState {
const spentDna = state.evolutionTree
.filter((n) => n.unlocked)
.reduce((sum, n) => sum + n.cost, 0);
return {
...state,
ancestralDna: state.ancestralDna + spentDna,
evolutionTree: state.evolutionTree.map((n) => ({ ...n, unlocked: false })),
};
}
// Compte l'ADN total investi dans l'arbre
export function getSpentDna(tree: EvolutionNode[]): number {
return tree.filter((n) => n.unlocked).reduce((sum, n) => sum + n.cost, 0);
}
// Calcule le multiplicateur click total depuis l'arbre // Calcule le multiplicateur click total depuis l'arbre
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number { export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
return tree return tree
@@ -96,6 +154,62 @@ export function getStartBonusFromTree(tree: EvolutionNode[]): number {
.reduce((sum, n) => sum + n.value, 0); .reduce((sum, n) => sum + n.value, 0);
} }
// Chance de double click (0-1)
export function getDoubleClickChance(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "double_click_chance")
.reduce((sum, n) => sum + n.value, 0);
}
// Auto-clicks par seconde depuis l'arbre
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "auto_click")
.reduce((sum, n) => sum + n.value, 0);
}
// Chance de crit click (0-1), crit = x10
export function getCritClickChance(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "crit_click_chance")
.reduce((sum, n) => sum + n.value, 0);
}
// Multiplicateur boost sur Nid (generator_boost)
export function getGeneratorBoostFromTree(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "generator_boost")
.reduce((mult, n) => mult * n.value, 1);
}
// Réduction de coût générateurs (0-1)
export function getCostReduction(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "cost_reduction")
.reduce((sum, n) => sum + n.value, 0);
}
// Bonus ADN prestige (additif, ex: 0.25 = +25%)
export function getPrestigeDnaBonus(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "prestige_dna_bonus")
.reduce((sum, n) => sum + n.value, 0);
}
// Boost offline (additif, ex: 0.50 = +50% efficacité offline)
export function getOfflineBoost(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "offline_boost")
.reduce((sum, n) => sum + n.value, 0);
}
// Réduction seuil prestige (multiplicatif, ex: 0.50 = seuil divisé par 2)
export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
return tree
.filter((n) => n.unlocked && n.effect === "prestige_threshold_reduction")
.reduce((sum, n) => sum + n.value, 0);
}
// --- Offline gains (courbe inversée) --- // --- Offline gains (courbe inversée) ---
const OFFLINE_THRESHOLD = 60_000; // 60s — en-dessous = idle normal, au-dessus = offline const OFFLINE_THRESHOLD = 60_000; // 60s — en-dessous = idle normal, au-dessus = offline
@@ -131,6 +245,8 @@ export function computeOfflineGains(state: GameState, now: number): number {
const pps = totalProductionPerSecond(state); const pps = totalProductionPerSecond(state);
if (pps <= 0) return 0; if (pps <= 0) return 0;
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree);
// Intégration par tranches de 60s // Intégration par tranches de 60s
const STEP = 60_000; const STEP = 60_000;
let total = 0; let total = 0;
@@ -139,20 +255,27 @@ export function computeOfflineGains(state: GameState, now: number): number {
const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche
total += pps * (chunk / 1000) * eff; total += pps * (chunk / 1000) * eff;
} }
return total; return total * offlineBoost;
} }
// --- Core economy (mis à jour pour intégrer l'arbre) --- // --- Core economy (mis à jour pour intégrer l'arbre) ---
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned // Coût d'achat du N-ième générateur : baseCost × 1.15^owned × (1 - costReduction)
export function generatorCost(gen: Generator): number { export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned)); const base = Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
if (!tree) return base;
const reduction = getCostReduction(tree);
return Math.max(1, Math.floor(base * (1 - reduction)));
} }
// Production totale par seconde de tous les générateurs // Production totale par seconde de tous les générateurs
export function totalProductionPerSecond(state: GameState): number { export function totalProductionPerSecond(state: GameState): number {
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
const base = state.generators.reduce( const base = state.generators.reduce(
(sum, gen) => sum + gen.baseProduction * gen.owned, (sum, gen) => {
const boost = gen.id === "nid" ? nidBoost : 1;
return sum + gen.baseProduction * gen.owned * boost;
},
0 0
); );
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree); const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
@@ -198,7 +321,7 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
if (genIndex === -1) return null; if (genIndex === -1) return null;
const gen = state.generators[genIndex]; const gen = state.generators[genIndex];
const cost = generatorCost(gen); const cost = generatorCost(gen, state.evolutionTree);
if (state.resources < cost) return null; if (state.resources < cost) return null;
const updatedGenerators = [...state.generators]; const updatedGenerators = [...state.generators];
@@ -212,13 +335,22 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
} }
// Prestige : reset run, gain ADN, arbre persiste // 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);
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
}
export function canPrestige(state: GameState): boolean { export function canPrestige(state: GameState): boolean {
return state.resources >= 1_000_000; return state.resources >= getPrestigeThreshold(state);
} }
export function applyPrestige(state: GameState): GameState { export function applyPrestige(state: GameState): GameState {
const newPrestigeCount = state.prestigeCount + 1; const newPrestigeCount = state.prestigeCount + 1;
const dnaGained = computePrestigeDna(state.lifetimeTadpoles); const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const baseDna = computePrestigeDna(state.lifetimeTadpoles);
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
const startBonus = getStartBonusFromTree(state.evolutionTree); const startBonus = getStartBonusFromTree(state.evolutionTree);
return { return {

View File

@@ -10,6 +10,7 @@ import {
applyClick, applyClick,
buyGenerator, buyGenerator,
buyEvolutionNode, buyEvolutionNode,
resetEvolutionTree,
applyPrestige, applyPrestige,
canPrestige as canPrestigeCheck, canPrestige as canPrestigeCheck,
totalProductionPerSecond, totalProductionPerSecond,
@@ -65,10 +66,12 @@ interface GameStore {
buy: (genId: string) => void; buy: (genId: string) => void;
buyNode: (nodeId: string) => void; buyNode: (nodeId: string) => void;
prestige: () => void; prestige: () => void;
resetTree: () => void;
reset: () => void; reset: () => void;
loadFromServer: (serverState: GameState) => void; loadFromServer: (serverState: GameState) => void;
initGuest: () => void; initGuest: () => void;
generatorCost: typeof genCost; generatorCost: typeof genCost;
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => number;
} }
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } { function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
@@ -194,6 +197,18 @@ export const useGameStore = create<GameStore>((set, get) => ({
}); });
}, },
resetTree: () => {
if (!get().ready) return;
set((s) => {
const updated = resetEvolutionTree(s.state);
saveLocal(updated);
return {
state: updated,
productionPerSecond: totalProductionPerSecond(updated),
};
});
},
reset: () => { reset: () => {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh); saveLocal(fresh);
@@ -233,4 +248,5 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
generatorCost: genCost, generatorCost: genCost,
generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree),
})); }));