feat: migrate frontend React 18 → Svelte 5 + SvelteKit
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s

Core logic portable (economy, balance, cosmetics, migrateSave) — zero rewrite.
136 tests green, identiques. Backend inchangé.

- Svelte 5 runes stores (game, auth, toast) remplacent Zustand
- SvelteKit adapter-static SPA (dist/ output, fallback index.html)
- Tailwind v4 conservé, design system .gp-* porté
- Transitions natives : slide, fly, scale, fade sur toute l'UI
- Sidebar tabbée (Production/Evolution/Collection) + CollapsiblePanel
- Mobile bottom sheet avec FAB toggle + backdrop blur
- Click particles réactifs Svelte (plus de DOM impératif)
- TadpoleSprite bounce + glow ring au clic
- Guide refait en accordéon, Achievements avec filtres
- a11y : focus-visible, Escape modals, aria-current, aria-labels
- CI/CD adapté (tests + build + rsync)
- Build 504K (vs ~1.2MB React)
This commit is contained in:
2026-03-28 20:03:21 +01:00
parent 3de0492631
commit f6bff6e389
125 changed files with 5323 additions and 10373 deletions

View File

@@ -0,0 +1,776 @@
// 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 totale par seconde de tous les générateurs
export function totalProductionPerSecond(state: GameState): number {
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
const base = state.generators.reduce(
(sum, gen) => {
const boost = gen.id === "nid" ? nidBoost : 1;
return sum + gen.baseProduction * gen.owned * boost;
},
0
);
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
}
// 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,
},
};
}
// Gain de base par clic (sans RNG — pour affichage tooltip)
export function getClickGain(state: GameState): number {
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
}
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): GameState | null {
const genIndex = state.generators.findIndex((g) => g.id === genId);
if (genIndex === -1) return null;
const gen = state.generators[genIndex];
const cost = generatorCost(gen, state.evolutionTree);
if (state.resources < cost) return null;
const updatedGenerators = [...state.generators];
updatedGenerators[genIndex] = { ...gen, owned: gen.owned + 1 };
return {
...state,
resources: state.resources - cost,
generators: updatedGenerators,
};
}
// Prestige : reset run, gain ADN, arbre persiste
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 >= 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: [],
};