All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 20s
clickGain = base × prestige × tree × (1 + types×2 + totalOwned×0.05) Click power is now its own system: - Each generator TYPE owned: +2 to click mult (diversity = power) - Each generator UNIT owned: +0.05 (stacking helps but less) - 5 types × 10 each = x13.5 click multiplier from infra alone - Decoupled from prod/s — buying generators boosts BOTH systems ClickPanel shows infra breakdown (types bonus + units bonus).
856 lines
34 KiB
TypeScript
856 lines
34 KiB
TypeScript
// 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;
|
||
baseCost: number;
|
||
baseProduction: number; // ressource/s
|
||
owned: number;
|
||
}
|
||
|
||
export type EffectType =
|
||
| "click_multiplier"
|
||
| "production_multiplier"
|
||
| "start_bonus"
|
||
| "unlock_generator"
|
||
| "double_click_chance"
|
||
| "auto_click"
|
||
| "crit_click_chance"
|
||
| "generator_boost"
|
||
| "cost_reduction"
|
||
| "prestige_dna_bonus"
|
||
| "offline_boost"
|
||
| "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" | "cross";
|
||
|
||
export interface EvolutionNode {
|
||
id: string;
|
||
name: string;
|
||
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[];
|
||
lastTick: number; // timestamp ms — lazy calc reference
|
||
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
|
||
prestigeCount: number;
|
||
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
||
ancestralDna: number;
|
||
evolutionTree: EvolutionNode[];
|
||
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) — 10 nœuds ═══
|
||
|
||
// 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 },
|
||
|
||
// ═══ 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" },
|
||
];
|
||
|
||
// 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[] {
|
||
const claimed = state.claimedMilestones ?? [];
|
||
return PRESTIGE_MILESTONES.filter(
|
||
(m) => state.prestigeCount >= m.threshold && !claimed.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;
|
||
const claimed = state.claimedMilestones ?? [];
|
||
if (claimed.includes(milestoneId)) return null;
|
||
|
||
let newState = {
|
||
...state,
|
||
claimedMilestones: [...claimed, 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) 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;
|
||
}
|
||
// 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;
|
||
}
|
||
|
||
// Achète un nœud d'évolution (retourne null si impossible)
|
||
export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null {
|
||
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 - cost,
|
||
evolutionTree: state.evolutionTree.map((n) =>
|
||
n.id === nodeId ? { ...n, unlocked: true } : n
|
||
),
|
||
};
|
||
}
|
||
|
||
// 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 cost = getTreeResetCost(state);
|
||
if (state.ancestralDna < cost) return state;
|
||
|
||
const spentDna = getSpentDna(state.evolutionTree);
|
||
|
||
return {
|
||
...state,
|
||
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 (standard + repeatables + convergence upgrades)
|
||
export function getSpentDna(tree: EvolutionNode[]): number {
|
||
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
|
||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||
return tree
|
||
.filter((n) => n.unlocked && n.effect === "click_multiplier")
|
||
.reduce((mult, n) => mult * n.value, 1);
|
||
}
|
||
|
||
// Calcule le multiplicateur production total depuis l'arbre
|
||
export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number {
|
||
return tree
|
||
.filter((n) => n.unlocked && n.effect === "production_multiplier")
|
||
.reduce((mult, n) => mult * n.value, 1);
|
||
}
|
||
|
||
// Bonus de départ (têtards offerts au début de chaque run)
|
||
export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
||
return tree
|
||
.filter((n) => n.unlocked && n.effect === "start_bonus")
|
||
.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 (standard + capstone scaling)
|
||
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
|
||
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
|
||
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);
|
||
}
|
||
|
||
// --- Sprint 3 — Nouveaux effets ---
|
||
|
||
// 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
|
||
export function offlineEfficiency(elapsedMs: number): number {
|
||
if (elapsedMs <= OFFLINE_THRESHOLD) return 1; // pas offline
|
||
if (elapsedMs <= OFFLINE_FULL_MS) return 1; // 0-15min : 100%
|
||
if (elapsedMs <= OFFLINE_DECAY_END_MS) {
|
||
// 15min-1h : linéaire 1.0 → 0.25
|
||
const t = (elapsedMs - OFFLINE_FULL_MS) / (OFFLINE_DECAY_END_MS - OFFLINE_FULL_MS);
|
||
return 1 - t * (1 - OFFLINE_FLOOR);
|
||
}
|
||
if (elapsedMs <= OFFLINE_ZERO_MS) {
|
||
// 1h-2h : linéaire 0.25 → 0.0
|
||
const t = (elapsedMs - OFFLINE_DECAY_END_MS) / (OFFLINE_ZERO_MS - OFFLINE_DECAY_END_MS);
|
||
return OFFLINE_FLOOR * (1 - t);
|
||
}
|
||
return 0; // >2h : rien
|
||
}
|
||
|
||
// Calcule les gains offline avec la courbe dégressive
|
||
// Intègre la courbe par tranches de 1 minute pour plus de précision
|
||
export function computeOfflineGains(state: GameState, now: number): number {
|
||
const elapsed = now - state.lastTick;
|
||
if (elapsed <= OFFLINE_THRESHOLD) return computeIdleGains(state, now);
|
||
|
||
const pps = totalProductionPerSecond(state);
|
||
if (pps <= 0) return 0;
|
||
|
||
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state);
|
||
|
||
// Intégration par tranches de 60s
|
||
const STEP = 60_000;
|
||
let total = 0;
|
||
for (let t = 0; t < elapsed; t += STEP) {
|
||
const chunk = Math.min(STEP, elapsed - t);
|
||
const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche
|
||
total += pps * (chunk / 1000) * eff;
|
||
}
|
||
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 × (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 effective d'un seul générateur (avec tous les bonus appliqués)
|
||
export function generatorEffectiveProduction(gen: Generator, state: GameState): number {
|
||
if (gen.owned === 0) return 0;
|
||
const nidBoost = gen.id === "nid" ? getGeneratorBoostFromTree(state.evolutionTree) : 1;
|
||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||
return gen.baseProduction * gen.owned * nidBoost * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||
}
|
||
|
||
// Production totale par seconde de tous les générateurs
|
||
export function totalProductionPerSecond(state: GameState): number {
|
||
return state.generators.reduce((sum, gen) => sum + generatorEffectiveProduction(gen, state), 0);
|
||
}
|
||
|
||
// Lazy calculation : ressources accumulées depuis lastTick
|
||
export function computeIdleGains(state: GameState, now: number): number {
|
||
const elapsedSeconds = (now - state.lastTick) / 1000;
|
||
return totalProductionPerSecond(state) * elapsedSeconds;
|
||
}
|
||
|
||
// Applique les gains idle et met à jour lastTick
|
||
export function applyIdleGains(state: GameState, now: number): GameState {
|
||
const gains = computeIdleGains(state, now);
|
||
return {
|
||
...state,
|
||
resources: state.resources + gains,
|
||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||
lastTick: now,
|
||
runStats: {
|
||
...state.runStats,
|
||
tadpolesProduced: state.runStats.tadpolesProduced + gains,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Bonus clic depuis les générateurs (diversité + quantité)
|
||
export function getGeneratorClickBonus(generators: Generator[]): number {
|
||
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||
const totalOwned = generators.reduce((sum, g) => sum + g.owned, 0);
|
||
return 1 + typesOwned * 2 + totalOwned * 0.05;
|
||
}
|
||
|
||
// Gain par clic — scaling propre : base × prestige × arbre × generateurs
|
||
export function getClickGain(state: GameState): number {
|
||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||
const genBonus = getGeneratorClickBonus(state.generators);
|
||
return Math.floor(state.clickMultiplier * state.prestigeMultiplier * treeClickMult * genBonus);
|
||
}
|
||
|
||
// Breakdown complet du clic (pour affichage cockpit)
|
||
export interface ClickBreakdown {
|
||
base: number;
|
||
prestigeMult: number;
|
||
treeMult: number;
|
||
genBonus: number; // multiplicateur depuis generateurs (types + quantite)
|
||
genTypes: number; // types possedes
|
||
genTotal: number; // total unites possedees
|
||
total: number; // gain par clic (floor)
|
||
doubleChance: number;
|
||
critChance: number;
|
||
autoClicksPerSec: number;
|
||
effectivePerSec: number;
|
||
}
|
||
|
||
export function getClickBreakdown(state: GameState): ClickBreakdown {
|
||
const base = state.clickMultiplier;
|
||
const prestigeMult = state.prestigeMultiplier;
|
||
const treeMult = getClickMultiplierFromTree(state.evolutionTree);
|
||
const genBonus = getGeneratorClickBonus(state.generators);
|
||
const genTypes = state.generators.filter((g) => g.owned > 0).length;
|
||
const genTotal = state.generators.reduce((sum, g) => sum + g.owned, 0);
|
||
const total = Math.floor(base * prestigeMult * treeMult * genBonus);
|
||
const doubleChance = getDoubleClickChance(state.evolutionTree);
|
||
const critChance = getCritClickChance(state.evolutionTree);
|
||
const autoClicksPerSec = getAutoClicksPerSecond(state.evolutionTree);
|
||
const effectivePerSec = autoClicksPerSec * total;
|
||
|
||
return { base, prestigeMult, treeMult, genBonus, genTypes, genTotal, total, doubleChance, critChance, autoClicksPerSec, effectivePerSec };
|
||
}
|
||
|
||
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: {
|
||
...state,
|
||
resources: state.resources + gain,
|
||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||
runStats: {
|
||
...state.runStats,
|
||
tadpolesProduced: state.runStats.tadpolesProduced + gain,
|
||
},
|
||
},
|
||
gain,
|
||
isDouble,
|
||
isCrit,
|
||
};
|
||
}
|
||
|
||
// Achat d'un générateur (retourne null si fonds insuffisants)
|
||
export function buyGenerator(state: GameState, genId: string, quantity = 1): GameState | null {
|
||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||
if (genIndex === -1) return null;
|
||
|
||
let gen = { ...state.generators[genIndex] };
|
||
let resources = state.resources;
|
||
|
||
let bought = 0;
|
||
for (let i = 0; i < quantity; i++) {
|
||
const cost = generatorCost(gen, state.evolutionTree);
|
||
if (resources < cost) break;
|
||
resources -= cost;
|
||
gen = { ...gen, owned: gen.owned + 1 };
|
||
bought++;
|
||
}
|
||
|
||
if (bought === 0) return null;
|
||
|
||
const updatedGenerators = [...state.generators];
|
||
updatedGenerators[genIndex] = gen;
|
||
|
||
return { ...state, resources, generators: updatedGenerators };
|
||
}
|
||
|
||
// Calcule combien d'unités on peut acheter avec les ressources actuelles
|
||
export function maxAffordable(state: GameState, genId: string): number {
|
||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||
if (genIndex === -1) return 0;
|
||
|
||
let gen = { ...state.generators[genIndex] };
|
||
let resources = state.resources;
|
||
let count = 0;
|
||
|
||
while (true) {
|
||
const cost = generatorCost(gen, state.evolutionTree);
|
||
if (resources < cost) break;
|
||
resources -= cost;
|
||
gen = { ...gen, owned: gen.owned + 1 };
|
||
count++;
|
||
if (count > 1000) break; // safety
|
||
}
|
||
return count;
|
||
}
|
||
|
||
// Cout total pour acheter N unités
|
||
export function bulkCost(state: GameState, genId: string, quantity: number): number {
|
||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||
if (genIndex === -1) return Infinity;
|
||
|
||
let gen = { ...state.generators[genIndex] };
|
||
let total = 0;
|
||
for (let i = 0; i < quantity; i++) {
|
||
total += generatorCost(gen, state.evolutionTree);
|
||
gen = { ...gen, owned: gen.owned + 1 };
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// Prestige : reset run, gain ADN, arbre persiste
|
||
|
||
export function getPrestigeThreshold(state: GameState): number {
|
||
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||
const scaling = Math.pow(1 + 0.1 * state.prestigeCount, 2);
|
||
return Math.floor(BASE_PRESTIGE_THRESHOLD * scaling * (1 - reduction));
|
||
}
|
||
|
||
export function canPrestige(state: GameState): boolean {
|
||
return state.resources >= getPrestigeThreshold(state);
|
||
}
|
||
|
||
export function applyPrestige(state: GameState): GameState {
|
||
const newPrestigeCount = state.prestigeCount + 1;
|
||
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||
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"
|
||
);
|
||
// 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 :
|
||
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
|
||
0,
|
||
})),
|
||
prestigeCount: newPrestigeCount,
|
||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||
ancestralDna: state.ancestralDna + dnaGained,
|
||
lifetimeTadpoles: 0,
|
||
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
|
||
};
|
||
}
|
||
|
||
// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier)
|
||
export const DEFAULT_GENERATORS: Generator[] = [
|
||
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
||
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
||
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
||
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
||
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
||
];
|
||
|
||
export const DEFAULT_STATE: GameState = {
|
||
saveVersion: CURRENT_SAVE_VERSION,
|
||
resources: 0,
|
||
clickMultiplier: 1,
|
||
generators: DEFAULT_GENERATORS,
|
||
lastTick: Date.now(),
|
||
lastOnline: Date.now(),
|
||
prestigeCount: 0,
|
||
prestigeMultiplier: 1,
|
||
ancestralDna: 0,
|
||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||
lifetimeTadpoles: 0,
|
||
cosmeticInventory: [],
|
||
cosmeticEquipped: {},
|
||
runStats: {
|
||
startedAt: Date.now(),
|
||
tadpolesProduced: 0,
|
||
bestRun: null,
|
||
},
|
||
freeResetAvailable: true,
|
||
extraResetsUsed: 0,
|
||
claimedMilestones: [],
|
||
};
|