feat: migrate frontend React 18 → Svelte 5 + SvelteKit
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
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:
776
Frontend/src/lib/core/economy.ts
Normal file
776
Frontend/src/lib/core/economy.ts
Normal 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: [],
|
||||
};
|
||||
Reference in New Issue
Block a user