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:
@@ -7,12 +7,16 @@ import {
|
||||
buyGenerator,
|
||||
applyPrestige,
|
||||
canPrestige,
|
||||
getPrestigeThreshold,
|
||||
computePrestigeDna,
|
||||
canBuyEvolutionNode,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
getClickMultiplierFromTree,
|
||||
getProductionMultiplierFromTree,
|
||||
getStartBonusFromTree,
|
||||
getPrestigeDnaBonus,
|
||||
getCostReduction,
|
||||
offlineEfficiency,
|
||||
computeOfflineGains,
|
||||
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", () => {
|
||||
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 };
|
||||
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", () => {
|
||||
const state = { ...DEFAULT_STATE, ancestralDna: 0 };
|
||||
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);
|
||||
expect(canBuyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBe(false);
|
||||
});
|
||||
|
||||
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
|
||||
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é", () => {
|
||||
@@ -301,7 +304,32 @@ describe("Evolution Tree", () => {
|
||||
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", () => {
|
||||
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree");
|
||||
expect(result).toBeNull();
|
||||
expect(buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEvolutionTree", () => {
|
||||
it("rembourse tout l'ADN dépensé et relock tous les nœuds", () => {
|
||||
const state = {
|
||||
...DEFAULT_STATE,
|
||||
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 modifie pas les autres nœuds", () => {
|
||||
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
|
||||
const result = buyEvolutionNode(state, "ponte_amelioree")!;
|
||||
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree");
|
||||
expect(otherNodes.every((n) => n.unlocked === false)).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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -365,6 +415,57 @@ describe("Evolution Tree", () => {
|
||||
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) ---
|
||||
|
||||
@@ -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 { canBuyEvolutionNode } from "../core/economy";
|
||||
import type { EvolutionNode } from "../core/economy";
|
||||
import { canBuyEvolutionNode, getSpentDna } from "../core/economy";
|
||||
import type { EvolutionNode, Branch } from "../core/economy";
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number) => string> = {
|
||||
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`,
|
||||
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`,
|
||||
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({
|
||||
node,
|
||||
canBuy,
|
||||
isExcluded,
|
||||
onBuy,
|
||||
}: {
|
||||
node: EvolutionNode;
|
||||
canBuy: boolean;
|
||||
isExcluded: boolean;
|
||||
onBuy: () => void;
|
||||
}) {
|
||||
const rowClass = node.unlocked
|
||||
? "gp-row gp-row--unlocked"
|
||||
: isExcluded
|
||||
? "gp-row gp-row--locked opacity-30!"
|
||||
: canBuy
|
||||
? "gp-row gp-row--evolution"
|
||||
: "gp-row gp-row--locked";
|
||||
@@ -30,45 +48,101 @@ function NodeRow({
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="gp-value">{node.name}</span>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect](node.value)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
{node.unlocked ? (
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
) : isExcluded ? (
|
||||
<span className="gp-label text-[0.55rem]!">verrouillé</span>
|
||||
) : (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionTree() {
|
||||
function BranchColumn({ branch }: { branch: Branch }) {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const { evolutionTree, prestigeCount } = state;
|
||||
|
||||
if (prestigeCount < 1) return null;
|
||||
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
|
||||
const config = BRANCH_CONFIG[branch];
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="gp-title">Évolution</span>
|
||||
<span className="gp-value gp-accent-amber">{state.ancestralDna} ADN</span>
|
||||
</div>
|
||||
{evolutionTree.map((node) => (
|
||||
<NodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||
onBuy={() => buyNode(node.id)}
|
||||
/>
|
||||
))}
|
||||
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
|
||||
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
|
||||
{nodes.map((node) => {
|
||||
const isExcluded = node.exclusive_with
|
||||
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
|
||||
: false;
|
||||
return (
|
||||
<NodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||
isExcluded={isExcluded}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function GeneratorShop() {
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||
const buy = useGameStore((s) => s.buy);
|
||||
const generatorCost = useGameStore((s) => s.generatorCost);
|
||||
const generatorCost = useGameStore((s) => s.generatorCostWithTree);
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// MilestoneBar.tsx — Progression vers le prochain prestige
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
const PRESTIGE_THRESHOLD = 1_000_000;
|
||||
import { formatNumber, } from "../utils/formatNumber";
|
||||
import { getPrestigeThreshold } from "../core/economy";
|
||||
|
||||
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 remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0);
|
||||
const remaining = Math.max(threshold - resources, 0);
|
||||
|
||||
return (
|
||||
<div className="gp gap-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="gp-label">Prochaine Génération</span>
|
||||
<span className="gp-label">
|
||||
{formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)}
|
||||
{formatNumber(resources)} / {formatNumber(threshold)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-progress">
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// PrestigePanel.tsx — Nouvelle Génération (prestige)
|
||||
|
||||
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() {
|
||||
const { lifetimeTadpoles } = useGameStore((s) => s.state);
|
||||
const canPrestige = useGameStore((s) => s.canPrestige);
|
||||
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 confirmed = window.confirm(
|
||||
@@ -35,7 +40,7 @@ export function PrestigePanel() {
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -9,16 +9,33 @@ export interface Generator {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
cost: number; // en ADN Ancestral
|
||||
cost: number; // en ADN Ancestral
|
||||
effect: EffectType;
|
||||
value: number;
|
||||
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 {
|
||||
@@ -37,11 +54,29 @@ export interface GameState {
|
||||
// --- Arbre d'Évolution ---
|
||||
|
||||
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 },
|
||||
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: "ponte_amelioree" },
|
||||
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 10, effect: "start_bonus", value: 100, unlocked: false, requires: "instinct_gregaire" },
|
||||
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" },
|
||||
{ id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" },
|
||||
// --- 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: "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))
|
||||
@@ -58,6 +93,11 @@ export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
@@ -96,6 +154,62 @@ export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
||||
.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) ---
|
||||
|
||||
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);
|
||||
if (pps <= 0) return 0;
|
||||
|
||||
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree);
|
||||
|
||||
// Intégration par tranches de 60s
|
||||
const STEP = 60_000;
|
||||
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
|
||||
total += pps * (chunk / 1000) * eff;
|
||||
}
|
||||
return total;
|
||||
return total * offlineBoost;
|
||||
}
|
||||
|
||||
// --- Core economy (mis à jour pour intégrer l'arbre) ---
|
||||
|
||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned
|
||||
export function generatorCost(gen: Generator): number {
|
||||
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned × (1 - costReduction)
|
||||
export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
|
||||
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
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
|
||||
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
|
||||
);
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
@@ -198,7 +321,7 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
|
||||
if (genIndex === -1) return null;
|
||||
|
||||
const gen = state.generators[genIndex];
|
||||
const cost = generatorCost(gen);
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (state.resources < cost) return null;
|
||||
|
||||
const updatedGenerators = [...state.generators];
|
||||
@@ -212,13 +335,22 @@ 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);
|
||||
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
|
||||
}
|
||||
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
return state.resources >= 1_000_000;
|
||||
return state.resources >= getPrestigeThreshold(state);
|
||||
}
|
||||
|
||||
export function applyPrestige(state: GameState): GameState {
|
||||
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);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
applyClick,
|
||||
buyGenerator,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
applyPrestige,
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
@@ -65,10 +66,12 @@ interface GameStore {
|
||||
buy: (genId: string) => void;
|
||||
buyNode: (nodeId: string) => void;
|
||||
prestige: () => void;
|
||||
resetTree: () => void;
|
||||
reset: () => void;
|
||||
loadFromServer: (serverState: GameState) => void;
|
||||
initGuest: () => void;
|
||||
generatorCost: typeof genCost;
|
||||
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => number;
|
||||
}
|
||||
|
||||
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: () => {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
saveLocal(fresh);
|
||||
@@ -233,4 +248,5 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
},
|
||||
|
||||
generatorCost: genCost,
|
||||
generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user