Files
ClickerZ/Frontend/src/lib/core/economy.ts
Tetardtek f9dd4c3ca4
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 20s
feat: click scales with generators (types + quantity), not prod/s
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).
2026-03-28 21:13:20 +01:00

856 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: [],
};