feat: Sprint 3 — Prestige Loop endless
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 35s

- Migration saves: saveVersion pattern + migrateSave lazy (v1→v2)
- Formule ADN rebalancée: log10 + clamp min 1 + cap bonus ×4
- Prestige Experience: modal fullscreen, preview ADN, stats run, best run
- Arbre V2: 25 nœuds, 3 capstones, post-capstones repeatables (scaling par tranche)
- Convergence évolutif Alpha→Omega (tier system)
- Reset arbre: 1 gratuit/prestige, payant linéaire au-delà
- Milestones prestige: 8 paliers (1→100), cosmétiques exclusifs, bonus gameplay
- balance.ts: constantes centralisées pour playtest
- 136 tests green, 0 regression
This commit is contained in:
2026-03-28 18:24:24 +01:00
parent f80f071c24
commit ed8cf87d4e
22 changed files with 1917 additions and 158 deletions

View File

@@ -1,28 +1,43 @@
// EvolutionTree.tsx — Arbre d'Évolution 3 voies (permanent — jamais reset par prestige)
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
import { useGameStore } from "../store/useGameStore";
import { canBuyEvolutionNode, getSpentDna } from "../core/economy";
import {
canBuyEvolutionNode,
getSpentDna,
getTreeResetCost,
canResetTree,
getRepeatableCost,
canUpgradeConvergence,
} from "../core/economy";
import type { EvolutionNode, Branch } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
const EFFECT_LABELS: Record<string, (v: number) => string> = {
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
click_multiplier: (v) => `x${v} ponte`,
production_multiplier: (v) => `x${v} production`,
start_bonus: (v) => `+${v} têtards au départ`,
unlock_generator: () => `Lac Mystique dès le début`,
start_bonus: (v) => `+${v} tetards au depart`,
unlock_generator: () => `Lac Mystique des le debut`,
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
auto_click: (v) => `${v} auto-ponte/s`,
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
generator_boost: (v) => `x${v} Nid`,
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% coût générateurs`,
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
offline_boost: (v) => `+${(v * 100).toFixed(0)}% gains offline`,
prestige_threshold_reduction: (v) => `Prestige à ${((1 - v) * 100).toFixed(0)}% du seuil`,
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
};
const BRANCH_CONFIG: Record<Branch, { label: string; color: string; accent: string }> = {
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
};
function NodeRow({
@@ -36,36 +51,62 @@ function NodeRow({
isExcluded: boolean;
onBuy: () => void;
}) {
const isCapstone = node.capstone;
const isRepeatable = node.repeatable;
const purchased = node.purchased ?? 0;
const rowClass = node.unlocked
? "gp-row gp-row--unlocked"
? isCapstone
? "gp-row gp-row--unlocked border-amber-400/40!"
: "gp-row gp-row--unlocked"
: isExcluded
? "gp-row gp-row--locked opacity-30!"
: canBuy
? "gp-row gp-row--evolution"
? isCapstone
? "gp-row gp-row--evolution border-amber-400/30!"
: "gp-row gp-row--evolution"
: "gp-row gp-row--locked";
const cost = isRepeatable && node.unlocked
? getRepeatableCost(node)
: isRepeatable
? node.cost
: node.cost;
return (
<div className={rowClass}>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
{isCapstone && <span className="text-amber-400 text-[0.6rem]"></span>}
<span className="gp-value text-[0.7rem]!">{node.name}</span>
{isRepeatable && node.unlocked && (
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
)}
{node.exclusive_with && !node.unlocked && !isExcluded && (
<span className="gp-label text-[0.55rem]!">OU</span>
)}
</div>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value) ?? node.effect}</span>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
</div>
{node.unlocked ? (
{node.unlocked && !isRepeatable ? (
<span className="gp-label gp-accent-green">OK</span>
) : node.unlocked && isRepeatable ? (
<button
disabled={!canBuy}
onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{formatNumber(cost)}
</button>
) : isExcluded ? (
<span className="gp-label text-[0.55rem]!">verrouillé</span>
<span className="gp-label text-[0.55rem]!">verrouille</span>
) : (
<button
disabled={!canBuy}
onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{node.cost}
{formatNumber(cost)}
</button>
)}
</div>
@@ -99,6 +140,74 @@ function BranchColumn({ branch }: { branch: Branch }) {
);
}
function ConvergenceSection() {
const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode);
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
const conv = state.evolutionTree.find((n) => n.id === "convergence");
if (!conv) return null;
const canBuy = canBuyEvolutionNode(state, "convergence");
const canUpgrade = canUpgradeConvergence(state);
const tier = conv.tier ?? 1;
const maxTier = conv.maxTier ?? 2;
const tierName = tier >= 2 ? "Omega" : "Alpha";
return (
<div className="gp border-t-2 border-purple-500/30">
<span className="gp-title text-center gp-accent-purple">
Convergence {conv.unlocked ? tierName : ""}
</span>
{conv.unlocked ? (
<div className="flex flex-col gap-1">
<div className="gp-row gp-row--unlocked border-purple-400/30!">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
</span>
<span className="gp-label">
{tier >= 2
? "+10% tous effets + -20% cout post-capstones"
: "+10% a tous les effets de l'arbre"
}
</span>
</div>
<span className="gp-label gp-accent-green">OK</span>
</div>
{tier < maxTier && (
<button
disabled={!canUpgrade}
onClick={upgradeConv}
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
>
{canUpgrade
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
}
</button>
)}
</div>
) : (
<div className="gp-row gp-row--locked">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
<span className="gp-label">+10% a tous les effets de l'arbre</span>
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
</div>
<button
disabled={!canBuy}
onClick={() => buyNode("convergence")}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{conv.cost}
</button>
</div>
)}
</div>
);
}
export function EvolutionTree() {
const state = useGameStore((s) => s.state);
const resetTree = useGameStore((s) => s.resetTree);
@@ -108,13 +217,16 @@ export function EvolutionTree() {
const spentDna = getSpentDna(evolutionTree);
const hasUnlocked = spentDna > 0;
const resetCost = getTreeResetCost(state);
const canReset = canResetTree(state);
const handleReset = () => {
if (!hasUnlocked) return;
if (!canReset) return;
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
const confirmed = window.confirm(
`Réinitialiser l'Arbre d'Évolution ?\n\n` +
`Tu récupères ${spentDna} ADN Ancestral.\n` +
`Tous les nœuds seront verrouillés.\n\n` +
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
`Tous les noeuds seront verrouilles.\n\n` +
`Confirmer ?`
);
if (confirmed) resetTree();
@@ -123,16 +235,21 @@ export function EvolutionTree() {
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center px-1">
<span className="gp-title">Évolution</span>
<span className="gp-title">Evolution</span>
<div className="flex items-center gap-2">
<span className="gp-value gp-accent-amber">{ancestralDna} ADN</span>
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
{hasUnlocked && (
<button
onClick={handleReset}
className="gp-btn gp-btn--disabled text-[0.55rem]! hover:bg-red-500/20! hover:text-red-400!"
title={`Récupérer ${spentDna} ADN`}
disabled={!canReset}
className={`gp-btn text-[0.55rem]! ${
canReset
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
: "gp-btn--disabled"
}`}
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
>
Reset
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
</button>
)}
</div>
@@ -142,6 +259,7 @@ export function EvolutionTree() {
<BranchColumn branch="marais" />
<BranchColumn branch="adaptation" />
</div>
<ConvergenceSection />
</div>
);
}

View File

@@ -0,0 +1,89 @@
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
// Progress bar vers le prochain milestone, claim button, preview reward
import { useGameStore } from "../store/useGameStore";
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
export function MilestonesPanel() {
const state = useGameStore((s) => s.state);
const claim = useGameStore((s) => s.claimMilestone);
if (state.prestigeCount < 1) return null;
const claimable = getClaimableMilestones(state);
const nextMilestone = getNextMilestone(state);
const totalClaimed = state.claimedMilestones.length;
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title">Milestones</span>
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
</div>
{/* Claimable milestones */}
{claimable.length > 0 && (
<div className="flex flex-col gap-1.5">
{claimable.map((m) => (
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
<div className="flex flex-col min-w-0">
<span className="gp-value text-[0.7rem]!">{m.name}</span>
<span className="gp-label">{m.reward.label}</span>
</div>
<button
onClick={() => claim(m.id)}
className="gp-btn gp-btn--buy"
>
Claim
</button>
</div>
))}
</div>
)}
{/* Progress vers le prochain milestone */}
{nextMilestone && (
<div className="flex flex-col gap-1">
<div className="flex justify-between">
<span className="gp-label">Prochain : {nextMilestone.name}</span>
<span className="gp-label">
{state.prestigeCount}/{nextMilestone.threshold}
</span>
</div>
<div className="gp-progress">
<div
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
style={{
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
}}
/>
</div>
<span className="gp-label">{nextMilestone.reward.label}</span>
</div>
)}
{/* Tous les milestones réclamés */}
{!nextMilestone && claimable.length === 0 && (
<span className="gp-label text-center gp-accent-purple">
Tous les milestones reclames !
</span>
)}
{/* Liste compacte des milestones passés */}
{totalClaimed > 0 && claimable.length === 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => (
<span
key={m.id}
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
title={`${m.name}${m.description}`}
>
{m.threshold}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -5,28 +5,15 @@ import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from ".
import { formatNumber } from "../utils/formatNumber";
export function PrestigePanel() {
const { lifetimeTadpoles } = useGameStore((s) => s.state);
const canPrestige = useGameStore((s) => s.canPrestige);
const prestige = useGameStore((s) => s.prestige);
const state = useGameStore((s) => s.state);
const baseDna = computePrestigeDna(lifetimeTadpoles);
const canPrestige = useGameStore((s) => s.canPrestige);
const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen);
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
const handlePrestige = () => {
const confirmed = window.confirm(
`Nouvelle Génération\n\n` +
`Reset : têtards et générateurs à zéro.\n` +
`Récompense : +${dnaPreview} ADN Ancestral\n` +
` +0.1x multiplicateur permanent\n\n` +
`L'Arbre d'Évolution persiste.\n\n` +
`Confirmer ?`
);
if (confirmed) prestige();
};
return (
<div className="gp">
<span className="gp-title" title="Recommence à zéro en échange d'un bonus permanent — tes têtards et générateurs sont réinitialisés mais tu gagnes de l'ADN et un multiplicateur">Prestige</span>
@@ -35,12 +22,12 @@ export function PrestigePanel() {
<span className="gp-value gp-accent-purple">
+{dnaPreview} ADN · +0.1x mult
</span>
<button onClick={handlePrestige} className="gp-btn gp-btn--prestige">
Nouvelle Génération
<button onClick={openPrestigeScreen} className="gp-btn gp-btn--prestige">
Nouvelle Generation
</button>
</div>
) : (
<span className="gp-label">Atteins {formatNumber(threshold)} têtards pour prestige</span>
<span className="gp-label">Atteins {formatNumber(threshold)} tetards pour prestige</span>
)}
</div>
);

View File

@@ -0,0 +1,182 @@
// PrestigeScreen.tsx — Écran de prestige fullscreen (Sprint 3)
// Preview ADN, stats de run, comparaison meilleure run, confirmation
import { useGameStore } from "../store/useGameStore";
import {
computePrestigeDna,
getPrestigeDnaBonus,
getPrestigeThreshold,
} from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function PrestigeScreen() {
const show = useGameStore((s) => s.showPrestigeScreen);
const close = useGameStore((s) => s.closePrestigeScreen);
const prestige = useGameStore((s) => s.prestige);
const state = useGameStore((s) => s.state);
if (!show) return null;
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
const canPrestige = state.lifetimeTadpoles >= threshold;
// Run stats
const now = Date.now();
const runDuration = now - state.runStats.startedAt;
const bestRun = state.runStats.bestRun;
// Comparison with best run
const isBestAdn = !bestRun || dnaPreview > bestRun.adn;
const isBestTadpoles = !bestRun || state.lifetimeTadpoles > bestRun.tadpoles;
const handlePrestige = () => {
if (canPrestige) prestige();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm">
<div className="gp max-w-md w-full mx-4">
{/* Header */}
<div className="text-center">
<span className="gp-title text-lg!">Nouvelle Generation</span>
<p className="gp-label mt-1">
Generation #{state.prestigeCount + 1}
</p>
</div>
<div className="gp-sep" />
{/* ADN Preview */}
<div className="flex flex-col items-center gap-1 py-2">
<span className="gp-label">ADN Ancestral</span>
<span className="text-3xl font-extrabold" style={{ color: "#a78bfa", fontFamily: "var(--font)" }}>
+{formatNumber(dnaPreview)}
</span>
{dnaBonus > 0 && (
<span className="gp-label">
(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)
</span>
)}
<span className="gp-label mt-1">
Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN
</span>
</div>
<div className="gp-sep" />
{/* Run Stats */}
<div className="flex flex-col gap-2">
<span className="gp-zone-label">Stats de la run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(runDuration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Tetards produits</span>
<span className={`gp-value ${isBestTadpoles ? "gp-accent-green" : ""}`}>
{formatNumber(state.lifetimeTadpoles)}
{isBestTadpoles && bestRun && " ★"}
</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN cette run</span>
<span className={`gp-value ${isBestAdn ? "gp-accent-green" : ""}`}>
{formatNumber(dnaPreview)}
{isBestAdn && bestRun && " ★"}
</span>
</div>
{bestRun && (
<div className="flex justify-between">
<span className="gp-label">Vitesse vs meilleure</span>
<span className={`gp-value ${
runDuration < bestRun.duration ? "gp-accent-green" : "gp-accent-amber"
}`}>
{runDuration < bestRun.duration
? `${Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide`
: runDuration > bestRun.duration
? `${Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent`
: "identique"
}
</span>
</div>
)}
</div>
{bestRun && (
<>
<div className="gp-sep" />
<div className="flex flex-col gap-1">
<span className="gp-zone-label">Meilleure run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(bestRun.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN</span>
<span className="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
</div>
</div>
</>
)}
<div className="gp-sep" />
{/* Reset info */}
<div className="text-center">
<p className="gp-label">
Tetards et generateurs remis a zero.
</p>
<p className="gp-label">
Arbre d'Evolution et cosmetiques conserves.
</p>
<p className="gp-label mt-1">
+1 reset d'arbre gratuit offert.
</p>
</div>
{/* Actions */}
<div className="flex gap-2 mt-1">
<button
onClick={close}
className="gp-btn flex-1 py-2!"
style={{ background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.7)" }}
>
Annuler
</button>
{canPrestige ? (
<button
onClick={handlePrestige}
className="gp-btn gp-btn--prestige flex-1 py-2!"
>
Nouvelle Generation
</button>
) : (
<button
className="gp-btn gp-btn--disabled flex-1 py-2!"
disabled
>
{formatNumber(threshold - state.lifetimeTadpoles)} tetards manquants
</button>
)}
</div>
</div>
</div>
);
}