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

@@ -1,56 +0,0 @@
// Centralized API client — cookie-based auth with 401 auto-refresh
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
let refreshPromise = null;
async function tryRefresh() {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch(`${BASE}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return res.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
export async function apiFetch(path, options = {}) {
const res = await fetch(`${BASE}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (res.status === 401 && path !== '/auth/refresh') {
const refreshed = await tryRefresh();
if (refreshed) {
const retry = await fetch(`${BASE}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (retry.ok) {
if (retry.status === 204) return null;
return retry.json();
}
}
window.dispatchEvent(new Event('auth:expired'));
throw new Error('Session expired');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(body.message || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}

58
Frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,58 @@
// Centralized API client — cookie-based auth with 401 auto-refresh
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch(`${BASE}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return res.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
export async function apiFetch(path: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(`${BASE}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (res.status === 401 && path !== '/auth/refresh') {
const refreshed = await tryRefresh();
if (refreshed) {
const retry = await fetch(`${BASE}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (retry.ok) {
if (retry.status === 204) return null;
return retry.json();
}
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('auth:expired'));
}
throw new Error('Session expired');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(body.message || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { formatNumber } from '$lib/utils/formatNumber';
interface Particle {
id: number;
x: number;
y: number;
gain: number;
isDouble: boolean;
isCrit: boolean;
}
let particles = $state<Particle[]>([]);
let nextId = 0;
export function spawn(x: number, y: number, gain: number, isDouble: boolean, isCrit: boolean) {
const id = nextId++;
// Random horizontal spread
const offsetX = (Math.random() - 0.5) * 40;
particles = [...particles, { id, x: x + offsetX, y, gain, isDouble, isCrit }];
setTimeout(() => {
particles = particles.filter(p => p.id !== id);
}, 1000);
}
</script>
<div class="fixed inset-0 pointer-events-none z-[100]">
{#each particles as p (p.id)}
{@const prefix = p.isCrit ? 'CRIT ' : p.isDouble ? 'x2 ' : ''}
{@const color = p.isCrit ? '#f59e0b' : p.isDouble ? '#a78bfa' : '#34d399'}
{@const size = p.isCrit ? '2rem' : p.isDouble ? '1.8rem' : '1.6rem'}
<span
class="absolute font-extrabold"
style="
left: {p.x}px;
top: {p.y}px;
color: {color};
font-size: {size};
font-family: var(--font);
text-shadow: 0 0 10px {color}60, 0 2px 6px rgba(0,0,0,0.7);
"
in:fly={{ y: 0, duration: 50 }}
out:fly={{ y: -90, duration: 900, easing: quintOut, opacity: 0 }}
>
{prefix}+{formatNumber(p.gain)}
</span>
{/each}
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
</script>
<div class="gp">
<div class="grid grid-cols-5 gap-0.5 px-1">
<div class="gp-stat" title="Production automatique par seconde">
<span class="gp-label">Prod/s</span>
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(gameStore.productionPerSecond)}</span>
</div>
<div class="gp-stat" title="Tetards gagnes par clic">
<span class="gp-label">/clic</span>
<span class="gp-value text-[0.8rem]!">{formatNumber(gameStore.getClickGain())}</span>
</div>
<div class="gp-stat" title="Multiplicateur global (prestige)">
<span class="gp-label">Mult</span>
<span class="gp-value text-[0.8rem]!">x{gameStore.state.prestigeMultiplier.toFixed(1)}</span>
</div>
<div class="gp-stat" title="ADN Ancestral">
<span class="gp-label">ADN</span>
<span class="gp-value gp-accent-purple text-[0.8rem]!">{gameStore.state.ancestralDna}</span>
</div>
<div class="gp-stat" title="Nombre de prestiges">
<span class="gp-label">Gen.</span>
<span class="gp-value text-[0.8rem]!">{gameStore.state.prestigeCount}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import type { Snippet } from 'svelte';
interface Props {
title: string;
badge?: string;
accentClass?: string;
defaultOpen?: boolean;
children: Snippet;
}
let { title, badge = '', accentClass = '', defaultOpen = true, children }: Props = $props();
let open = $state(defaultOpen);
</script>
<div class="gp overflow-hidden">
<button
class="flex items-center justify-between w-full cursor-pointer group"
onclick={() => open = !open}
>
<div class="flex items-center gap-2">
<svg
class="w-3 h-3 transition-transform duration-200 text-white/50 group-hover:text-white/80"
class:rotate-90={open}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="gp-title {accentClass}">{title}</span>
</div>
{#if badge}
<span class="gp-label">{badge}</span>
{/if}
</button>
{#if open}
<div transition:slide={{ duration: 250, easing: quintOut }}>
<div class="flex flex-col gap-[var(--spacing-gp-gap)] pt-1">
{@render children()}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
import CollapsiblePanel from './CollapsiblePanel.svelte';
const SLOT_LABELS: Record<CosmeticSlot, string> = {
hat: 'Tete', eyes: 'Yeux', body: 'Corps', tail: 'Queue', accessory: 'Aura',
};
const SLOT_ICONS: Record<CosmeticSlot, string> = {
hat: '👑', eyes: '👁', body: '🛡', tail: '🦎', accessory: '✨',
};
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
let inventory = $derived(gameStore.state.cosmeticInventory);
let equipped = $derived(gameStore.state.cosmeticEquipped);
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
</script>
{#if inventory.length > 0}
<CollapsiblePanel
title="Cosmetiques"
badge="{inventory.length}/{COSMETICS.length}"
defaultOpen={false}
>
{#each SLOT_ORDER as slot, si}
{@const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot)}
{#if slotCosmetics.length > 0}
<div
class="flex flex-col gap-0.5"
in:fly={{ y: 15, delay: si * 60, duration: 250, easing: quintOut }}
>
<span class="gp-zone-label">{SLOT_ICONS[slot]} {SLOT_LABELS[slot]}</span>
{#each slotCosmetics as cos}
{@const isEquipped = equipped[slot] === cos.id}
<div class="gp-row {isEquipped ? 'gp-row--unlocked' : 'gp-row--active'}">
<div class="flex flex-col min-w-0">
<span class="gp-value text-[0.7rem]!">{cos.name}</span>
<span class="gp-label">{cos.description}</span>
</div>
<button
onclick={() => isEquipped ? gameStore.unequipCosmetic(slot) : gameStore.equipCosmetic(cos.id)}
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
>
{isEquipped ? 'Retirer' : 'Equiper'}
</button>
</div>
{/each}
</div>
{/if}
{/each}
</CollapsiblePanel>
{/if}

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import {
canBuyEvolutionNode,
getSpentDna,
getTreeResetCost,
canResetTree,
getRepeatableCost,
canUpgradeConvergence,
type EvolutionNode,
type Branch,
} from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
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} tetards au depart`,
unlock_generator: () => `Lac Mystique des le debut`,
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
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`,
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, 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<string, { 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' },
};
const BRANCHES: Branch[] = ['ponte', 'marais', 'adaptation'];
let activeBranch = $state<Branch>('ponte');
let branchConfig = $derived(BRANCH_CONFIG[activeBranch]);
let branchNodes = $derived(gameStore.state.evolutionTree.filter((n) => n.branch === activeBranch));
let spentDna = $derived(getSpentDna(gameStore.state.evolutionTree));
let hasUnlocked = $derived(spentDna > 0);
let resetCost = $derived(getTreeResetCost(gameStore.state));
let canReset = $derived(canResetTree(gameStore.state));
let conv = $derived(gameStore.state.evolutionTree.find((n) => n.id === 'convergence'));
let canBuyConv = $derived(canBuyEvolutionNode(gameStore.state, 'convergence'));
let canUpgradeConv = $derived(canUpgradeConvergence(gameStore.state));
function handleReset() {
if (!canReset) return;
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : ' (gratuit)';
const confirmed = window.confirm(
`Reinitialiser l'Arbre d'Evolution ?\n\nTu recuperes ${spentDna} ADN Ancestral.${costLabel}\nTous les noeuds seront verrouilles.\n\nConfirmer ?`
);
if (confirmed) gameStore.resetTree();
}
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
if (node.unlocked) return node.capstone ? 'gp-row gp-row--unlocked border-amber-400/40!' : 'gp-row gp-row--unlocked';
if (isExcluded) return 'gp-row gp-row--locked opacity-30!';
if (canBuy) return node.capstone ? 'gp-row gp-row--evolution border-amber-400/30!' : 'gp-row gp-row--evolution';
return 'gp-row gp-row--locked';
}
</script>
{#if gameStore.state.prestigeCount >= 1}
<div class="flex flex-col gap-2">
<!-- Header -->
<div class="flex justify-between items-center px-1">
<span class="gp-title">Evolution</span>
<div class="flex items-center gap-2">
<span class="gp-value gp-accent-amber">{formatNumber(gameStore.state.ancestralDna)} ADN</span>
{#if hasUnlocked}
<button
onclick={handleReset}
disabled={!canReset}
class="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{resetCost > 0 ? ` (${resetCost})` : ''}
</button>
{/if}
</div>
</div>
<!-- Branch tabs -->
<div class="flex gap-1">
{#each BRANCHES as branch}
{@const config = BRANCH_CONFIG[branch]}
{@const isActive = activeBranch === branch}
<button
onclick={() => activeBranch = branch}
class="gp-btn flex-1 py-1.5! text-[0.7rem]! font-bold! uppercase! tracking-wider! {isActive ? `gp-btn--buy ${config.accent}` : 'gp-btn--disabled'}"
>
{config.label}
</button>
{/each}
</div>
<!-- Active branch -->
<div class="gp flex-1 min-w-0 border-t-2 {branchConfig.color}">
<span class="gp-title text-center {branchConfig.accent}">{branchConfig.label}</span>
{#each branchNodes as node}
{@const isExcluded = node.exclusive_with ? (gameStore.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
{@const canBuy = canBuyEvolutionNode(gameStore.state, node.id)}
{@const cost = node.repeatable && node.unlocked ? getRepeatableCost(node) : node.cost}
<div class={getNodeRowClass(node, isExcluded, canBuy)}>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1">
{#if node.capstone}<span class="text-amber-400 text-[0.6rem]">★</span>{/if}
<span class="gp-value text-[0.7rem]!">{node.name}</span>
{#if node.repeatable && node.unlocked}
<span class="gp-label text-[0.55rem]!">x{node.purchased ?? 0}</span>
{/if}
{#if node.exclusive_with && !node.unlocked && !isExcluded}
<span class="gp-label text-[0.55rem]!">OU</span>
{/if}
</div>
<span class="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
</div>
{#if node.unlocked && !node.repeatable}
<span class="gp-label gp-accent-green">OK</span>
{:else if isExcluded}
<span class="gp-label text-[0.55rem]!">verrouille</span>
{:else}
<button
disabled={!canBuy}
onclick={() => gameStore.buyNode(node.id)}
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
</button>
{/if}
</div>
{/each}
</div>
<!-- Convergence -->
{#if conv}
<div class="gp border-t-2 border-purple-500/30">
<span class="gp-title text-center gp-accent-purple">
Convergence {conv.unlocked ? ((conv.tier ?? 1) >= 2 ? 'Omega' : 'Alpha') : ''}
</span>
{#if conv.unlocked}
{@const tier = conv.tier ?? 1}
{@const maxTier = conv.maxTier ?? 2}
<div class="flex flex-col gap-1">
<div class="gp-row gp-row--unlocked border-purple-400/30!">
<div class="flex flex-col">
<span class="gp-value text-[0.7rem]!">{tier >= 2 ? 'Omega' : 'Alpha'} (tier {tier}/{maxTier})</span>
<span class="gp-label">
{tier >= 2 ? '+10% tous effets + -20% cout post-capstones' : "+10% a tous les effets de l'arbre"}
</span>
</div>
<span class="gp-label gp-accent-green">OK</span>
</div>
{#if tier < maxTier}
<button
disabled={!canUpgradeConv}
onclick={() => gameStore.upgradeConvergence()}
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
>
{canUpgradeConv ? `Evoluer Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
</button>
{/if}
</div>
{:else}
<div class="gp-row gp-row--locked">
<div class="flex flex-col">
<span class="gp-value text-[0.7rem]!">Convergence Alpha</span>
<span class="gp-label">+10% a tous les effets de l'arbre</span>
<span class="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
</div>
<button
disabled={!canBuyConv}
onclick={() => gameStore.buyNode('convergence')}
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{conv.cost}
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,8 @@
<footer class="footer">
<div class="footer-container">
<a href="/" aria-label="Accueil Clickerz">
<div class="footer-logo"></div>
</a>
</div>
<p class="copyright">&copy; {new Date().getFullYear()} Clickerz — Tetard Universe</p>
</footer>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { gameStore } from '$lib/stores/game.svelte';
import {
loadFromServer,
startAutoSave,
stopAutoSave,
setupVisibilitySync,
} from '$lib/save-sync';
onMount(async () => {
// Init auth
await authStore.init();
// Load save or init guest
if (authStore.user) {
const loaded = await loadFromServer();
if (!loaded && !gameStore.ready) {
gameStore.initGuest();
}
startAutoSave();
setupVisibilitySync();
} else {
gameStore.initGuest();
}
});
</script>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { gameStore } from '$lib/stores/game.svelte';
let interval: ReturnType<typeof setInterval> | undefined;
onMount(() => {
interval = setInterval(() => gameStore.tick(), 1000);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
</script>
<CollapsiblePanel
title="Generateurs"
badge="{formatNumber(gameStore.productionPerSecond)}/s"
accentClass=""
>
{#each gameStore.state.generators as gen, i}
{@const cost = gameStore.generatorCostWithTree(gen)}
{@const canAfford = gameStore.state.resources >= cost}
{@const currentProd = gen.baseProduction * gen.owned}
<div
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
in:scale={{ delay: i * 30, duration: 200, start: 0.95, easing: quintOut }}
>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5">
<span class="gp-value">{gen.name}</span>
{#if gen.owned > 0}
<span
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
style="background: rgba(16,185,129,0.15); color: var(--color-gp-accent-green);"
>
x{gen.owned}
</span>
{/if}
</div>
<span class="gp-label">
+{gen.baseProduction}/s
{#if gen.owned > 0}
<span class="gp-accent-green"> · {formatNumber(currentProd)}/s</span>
{/if}
</span>
</div>
<button
onclick={() => gameStore.buy(gen.id)}
disabled={!canAfford}
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
</button>
</div>
{/each}
</CollapsiblePanel>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import { getPrestigeThreshold } from '$lib/core/economy';
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let progress = $derived(Math.min(gameStore.state.resources / threshold, 1));
let progressPercent = $derived((progress * 100).toFixed(1));
let remaining = $derived(Math.max(threshold - gameStore.state.resources, 0));
</script>
<div class="gp gap-1">
<div class="flex justify-between">
<span class="gp-label">Prochaine Generation</span>
<span class="gp-label">{formatNumber(gameStore.state.resources)} / {formatNumber(threshold)}</span>
</div>
<div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div>
</div>
<span class="gp-label text-right">
{remaining > 0 ? `${formatNumber(remaining)} restants` : 'Nouvelle Generation disponible !'}
</span>
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { fly, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
import CollapsiblePanel from './CollapsiblePanel.svelte';
let claimable = $derived(getClaimableMilestones(gameStore.state));
let nextMilestone = $derived(getNextMilestone(gameStore.state));
let claimed = $derived(gameStore.state.claimedMilestones ?? []);
let totalClaimed = $derived(claimed.length);
</script>
{#if gameStore.state.prestigeCount >= 1}
<CollapsiblePanel
title="Milestones"
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
accentClass="gp-accent-amber"
>
{#if claimable.length > 0}
<div class="flex flex-col gap-1.5">
{#each claimable as m, i}
<div
class="gp-row gp-row--evolution border-purple-400/30!"
in:fly={{ y: 20, delay: i * 80, duration: 300, easing: quintOut }}
>
<div class="flex flex-col min-w-0">
<span class="gp-value text-[0.7rem]!">{m.name}</span>
<span class="gp-label">{m.reward.label}</span>
</div>
<button onclick={() => gameStore.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
Claim
</button>
</div>
{/each}
</div>
{/if}
{#if nextMilestone}
{@const progressPct = Math.min((gameStore.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
<div class="flex flex-col gap-1">
<div class="flex justify-between">
<span class="gp-label">Prochain : {nextMilestone.name}</span>
<span class="gp-label">{gameStore.state.prestigeCount}/{nextMilestone.threshold}</span>
</div>
<div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div>
</div>
<span class="gp-label">{nextMilestone.reward.label}</span>
</div>
{/if}
{#if !nextMilestone && claimable.length === 0}
<span class="gp-label text-center gp-accent-purple">Tous les milestones reclames !</span>
{/if}
{#if totalClaimed > 0 && claimable.length === 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each PRESTIGE_MILESTONES.filter((m) => claimed.includes(m.id)) as m, i}
<span
class="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}"
in:scale={{ delay: i * 40, duration: 200 }}
>
{m.threshold}
</span>
{/each}
</div>
{/if}
</CollapsiblePanel>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { page } from '$app/state';
import { authStore } from '$lib/stores/auth.svelte';
const navLinks = [
{ name: 'Jeu', url: '/jeu' },
{ name: 'Succes', url: '/achievements' },
{ name: 'Guide', url: '/guide' },
];
</script>
<header class="header-main" role="banner">
<a href="/" aria-label="Accueil Clickerz">
<img class="logo" alt="Clickerz" />
</a>
<nav class="navbar" aria-label="Navigation principale">
<ul class="nav-list" role="list">
{#each navLinks as link}
<li>
<a
href={link.url}
class="mainLink"
aria-current={page.url.pathname === link.url ? 'page' : undefined}
style={page.url.pathname === link.url ? 'color: var(--color-red-light); font-weight: 600;' : ''}
>
{link.name}
</a>
</li>
{/each}
</ul>
<div class="auth-nav">
{#if authStore.loading}
<span class="auth-nickname" aria-live="polite">...</span>
{:else if authStore.user}
<span class="auth-nickname">{authStore.user.nickname}</span>
<a href="/settings" class="auth-btn">Profil</a>
<button class="auth-btn" onclick={() => authStore.logout()} type="button">Deconnexion</button>
{:else}
<a href="/login" class="auth-btn">Connexion</a>
{/if}
</div>
</nav>
</header>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { fade, fly, scale } from 'svelte/transition';
import { backOut, quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60_000);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h${minutes % 60}m`;
return `${minutes}m`;
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.offlineReport) gameStore.dismissOfflineReport(); }} />
{#if gameStore.offlineReport}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
transition:fade={{ duration: 250 }}
onclick={() => gameStore.dismissOfflineReport()}
>
<div
class="gp max-w-sm w-full mx-4 text-center"
onclick={(e: MouseEvent) => e.stopPropagation()}
in:scale={{ duration: 400, start: 0.8, easing: backOut }}
out:scale={{ duration: 200 }}
>
<div in:fly={{ y: -15, delay: 100, duration: 350, easing: quintOut }}>
<h2 class="gp-title text-lg!">Retour au Marais</h2>
<p class="gp-label mt-2">
Absent pendant <span class="gp-accent-green">{formatDuration(gameStore.offlineReport.duration)}</span>
</p>
</div>
<div in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}>
<p
class="gp-value text-3xl! mt-4 mb-2 gp-accent-green"
style="text-shadow: 0 0 15px rgba(52,211,153,0.3);"
>
+{formatNumber(gameStore.offlineReport.gains)} tetards
</p>
</div>
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
Efficacite : {Math.round(gameStore.offlineReport.efficiency * 100)}%
</p>
<button
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
onclick={() => gameStore.dismissOfflineReport()}
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
>
Continuer
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
let baseDna = $derived(computePrestigeDna(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let progress = $derived(Math.min(gameStore.state.lifetimeTadpoles / threshold * 100, 100));
</script>
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
{#if gameStore.canPrestige}
<div class="flex flex-col gap-2" in:scale={{ duration: 300, start: 0.9, easing: quintOut }}>
<div class="flex items-center justify-between">
<span class="gp-value gp-accent-purple">+{dnaPreview} ADN</span>
<span class="gp-label">+0.1x mult</span>
</div>
<button onclick={() => gameStore.openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
Nouvelle Generation
</button>
</div>
{:else}
<div class="flex flex-col gap-1">
<span class="gp-label">Atteins {formatNumber(threshold)} tetards</span>
<div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progress.toFixed(1)}%"></div>
</div>
</div>
{/if}
</CollapsiblePanel>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
import { formatNumber } from '$lib/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`;
}
let baseDna = $derived(computePrestigeDna(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let canPrestige = $derived(gameStore.state.lifetimeTadpoles >= threshold);
let runDuration = $derived(Date.now() - gameStore.state.runStats.startedAt);
let bestRun = $derived(gameStore.state.runStats.bestRun);
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
let isBestTadpoles = $derived(!bestRun || gameStore.state.lifetimeTadpoles > bestRun.tadpoles);
function handlePrestige() {
if (canPrestige) gameStore.prestige();
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.showPrestigeScreen) gameStore.closePrestige(); }} />
{#if gameStore.showPrestigeScreen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);"
transition:fade={{ duration: 300 }}
>
<!-- Modal card -->
<div
class="gp max-w-md w-full mx-4"
in:scale={{ duration: 400, start: 0.85, easing: backOut }}
out:scale={{ duration: 200, start: 0.95 }}
>
<!-- Header with generation number -->
<div class="text-center" in:fly={{ y: -20, delay: 100, duration: 400, easing: quintOut }}>
<span class="gp-title text-lg!">Nouvelle Generation</span>
<p class="gp-label mt-1">Generation #{gameStore.state.prestigeCount + 1}</p>
</div>
<div class="gp-sep"></div>
<!-- ADN Preview — the hero number -->
<div
class="flex flex-col items-center gap-1 py-3"
in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}
>
<span class="gp-label">ADN Ancestral</span>
<span
class="text-4xl font-extrabold"
style="color: #a78bfa; font-family: var(--font); text-shadow: 0 0 20px rgba(167,139,250,0.4);"
>
+{formatNumber(dnaPreview)}
</span>
{#if dnaBonus > 0}
<span class="gp-label">(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)</span>
{/if}
<span class="gp-label mt-1">Total apres : {formatNumber(gameStore.state.ancestralDna + dnaPreview)} ADN</span>
</div>
<div class="gp-sep"></div>
<!-- Run Stats -->
<div class="flex flex-col gap-2" in:fly={{ y: 20, delay: 300, duration: 400, easing: quintOut }}>
<span class="gp-zone-label">Stats de la run</span>
<div class="flex justify-between">
<span class="gp-label">Duree</span>
<span class="gp-value">{formatDuration(runDuration)}</span>
</div>
<div class="flex justify-between">
<span class="gp-label">Tetards produits</span>
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
{formatNumber(gameStore.state.lifetimeTadpoles)}
{#if isBestTadpoles && bestRun}{/if}
</span>
</div>
<div class="flex justify-between">
<span class="gp-label">ADN cette run</span>
<span class="gp-value {isBestAdn ? 'gp-accent-green' : ''}">
{formatNumber(dnaPreview)}
{#if isBestAdn && bestRun}{/if}
</span>
</div>
{#if bestRun}
<div class="flex justify-between">
<span class="gp-label">Vitesse vs meilleure</span>
<span class="gp-value {runDuration < bestRun.duration ? 'gp-accent-green' : 'gp-accent-amber'}">
{#if runDuration < bestRun.duration}
{Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide
{:else if runDuration > bestRun.duration}
{Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent
{:else}
identique
{/if}
</span>
</div>
{/if}
</div>
{#if bestRun}
<div class="gp-sep"></div>
<div class="flex flex-col gap-1" in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}>
<span class="gp-zone-label">Meilleure run</span>
<div class="flex justify-between">
<span class="gp-label">Duree</span>
<span class="gp-value">{formatDuration(bestRun.duration)}</span>
</div>
<div class="flex justify-between">
<span class="gp-label">ADN</span>
<span class="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
</div>
</div>
{/if}
<div class="gp-sep"></div>
<!-- Reset info -->
<div class="text-center" in:fade={{ delay: 350, duration: 300 }}>
<p class="gp-label">Tetards et generateurs remis a zero.</p>
<p class="gp-label">Arbre d'Evolution et cosmetiques conserves.</p>
<p class="gp-label mt-1 gp-accent-green">+1 reset d'arbre gratuit offert.</p>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
<button
onclick={() => gameStore.closePrestige()}
class="gp-btn flex-1 py-2.5! text-[0.8rem]!"
style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6);"
>
Annuler
</button>
{#if canPrestige}
<button onclick={handlePrestige} class="gp-btn gp-btn--prestige flex-1 py-2.5! text-[0.8rem]!">
Nouvelle Generation
</button>
{:else}
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
{formatNumber(threshold - gameStore.state.lifetimeTadpoles)} manquants
</button>
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import type { Snippet } from 'svelte';
interface Tab {
id: string;
label: string;
icon: string;
}
interface Props {
tabs: Tab[];
activeTab?: string;
children: Snippet<[string]>;
}
let { tabs, activeTab = tabs[0]?.id ?? '', children }: Props = $props();
let current = $state(activeTab);
let direction = $state(1);
function switchTab(tabId: string) {
const oldIdx = tabs.findIndex(t => t.id === current);
const newIdx = tabs.findIndex(t => t.id === tabId);
direction = newIdx > oldIdx ? 1 : -1;
current = tabId;
}
</script>
<!-- Tab bar -->
<div class="flex gap-0.5 p-0.5 rounded-lg" style="background: rgba(255,255,255,0.04);">
{#each tabs as tab}
{@const isActive = current === tab.id}
<button
onclick={() => switchTab(tab.id)}
class="flex-1 flex items-center justify-center gap-1 py-2 px-1 rounded-md text-[0.7rem] font-semibold uppercase tracking-wider transition-all duration-200"
style={isActive
? 'background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.95); box-shadow: 0 1px 3px rgba(0,0,0,0.3);'
: 'background: transparent; color: rgba(255,255,255,0.4);'
}
style:font-family="var(--font)"
>
<span class="text-sm">{tab.icon}</span>
<span class="hidden sm:inline">{tab.label}</span>
</button>
{/each}
</div>
<!-- Tab content with directional fly -->
{#key current}
<div
in:fly={{ x: 30 * direction, duration: 200, easing: quintOut }}
out:fly={{ x: -30 * direction, duration: 150, easing: quintOut }}
class="flex flex-col gap-[var(--spacing-gp-gap)]"
>
{@render children(current)}
</div>
{/key}

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
let overlays = $derived(
SLOT_ORDER
.map((slot) => {
const cosId = gameStore.state.cosmeticEquipped[slot];
if (!cosId) return null;
return COSMETICS.find((c) => c.id === cosId) ?? null;
})
.filter((c) => c !== null)
);
// Click bounce animation
let bouncing = $state(false);
export function bounce() {
bouncing = true;
setTimeout(() => bouncing = false, 150);
}
</script>
<div
class="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px] transition-transform duration-100"
class:scale-[0.92]={bouncing}
class:rotate-[3deg]={bouncing}
style="filter: drop-shadow(0 0 20px rgba(52,211,153,0.15));"
>
<!-- Base sprite -->
<img
src="/svg/tadpole.svg"
alt="Tetard"
class="w-full h-full object-contain"
draggable="false"
/>
<!-- Cosmetic overlays -->
{#each overlays as cos}
<img
src={cos.svg}
alt={cos.name}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
draggable="false"
/>
{/each}
<!-- Glow ring on click -->
{#if bouncing}
<div
class="absolute inset-0 rounded-full"
style="
background: radial-gradient(circle, rgba(52,211,153,0.15) 0%, transparent 70%);
animation: click-ring 0.3s ease-out;
"
></div>
{/if}
</div>
<style>
@keyframes click-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.3); opacity: 0; }
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { fly, scale } from 'svelte/transition';
import { backOut, quintOut } from 'svelte/easing';
import { getToasts } from '$lib/stores/toast.svelte';
const variantStyles: Record<string, { color: string; border: string; bg: string }> = {
success: { color: '#34d399', border: 'rgba(52,211,153,0.3)', bg: 'rgba(16,185,129,0.08)' },
info: { color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.08)' },
reward: { color: '#fbbf24', border: 'rgba(251,191,36,0.3)', bg: 'rgba(245,158,11,0.08)' },
warning: { color: '#f87171', border: 'rgba(248,113,113,0.3)', bg: 'rgba(239,68,68,0.08)' },
};
</script>
{#if getToasts().length > 0}
<div class="fixed top-24 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{#each getToasts() as t (t.id)}
{@const style = variantStyles[t.variant] || variantStyles.info}
<div
class="pointer-events-auto px-4 py-2.5 rounded-xl font-semibold text-sm shadow-2xl"
style="
background: rgba(17,17,17,0.9);
backdrop-filter: blur(12px);
border: 1px solid {style.border};
color: {style.color};
font-family: var(--font);
box-shadow: 0 0 20px {style.bg}, 0 8px 32px rgba(0,0,0,0.4);
"
in:fly={{ x: 80, duration: 350, easing: backOut }}
out:scale={{ duration: 200, start: 0.95, opacity: 0 }}
role="alert"
>
{t.message}
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,68 @@
// balance.ts — Constantes d'équilibrage centralisées
// Toutes les valeurs de tuning en un seul fichier pour faciliter le playtest.
// Sprint 3 — session brainstorm 2026-03-28
// --- Formule ADN prestige ---
export const PRESTIGE_ADN_BASE = 50;
export const PRESTIGE_ADN_THRESHOLD = 1e6; // 1M têtards minimum pour prestige
export const PRESTIGE_BONUS_PER_PRESTIGE = 0.05; // +5% par prestige
export const PRESTIGE_BONUS_CAP = 3.0; // cap à ×4 total (80 prestiges)
export const PRESTIGE_ADN_MIN = 1; // clamp : jamais 0 ADN si seuil atteint
// --- Seuil prestige ---
export const BASE_PRESTIGE_THRESHOLD = 1_000_000; // 1M têtards
// --- Post-capstone scaling par tranche ---
export const POST_CAPSTONE_TIERS = [
{ maxPurchases: 5, multiplier: 1.5 }, // achats 1-5
{ maxPurchases: 10, multiplier: 1.8 }, // achats 6-10
{ maxPurchases: Infinity, multiplier: 2.0 }, // achats 11+
] as const;
/**
* Calcule le coût du N-ième achat post-capstone repeatable (0-indexed).
* Scaling par tranche : ×1.5 (achats 0-4), ×1.8 (5-9), ×2.0 (10+)
*/
export function postCapstoneCost(baseCost: number, purchased: number): number {
let cost = baseCost;
for (let i = 0; i < purchased; i++) {
if (i < 5) cost *= 1.5;
else if (i < 10) cost *= 1.8;
else cost *= 2.0;
}
return Math.floor(cost);
}
// --- Reset arbre ---
export const TREE_RESET_FREE_PER_PRESTIGE = 1; // 1 gratuit par prestige
export const TREE_RESET_EXTRA_COST = 5; // 5 ADN × n pour les resets supplémentaires
/**
* Coût du prochain reset arbre.
* 1 gratuit par prestige, puis linéaire (5 × n) au-delà.
*/
export function treeResetCost(freeResetAvailable: boolean, extraResetsUsed: number): number {
if (freeResetAvailable) return 0;
return TREE_RESET_EXTRA_COST * (extraResetsUsed + 1);
}
// --- Offline ---
export const OFFLINE_THRESHOLD_MS = 60_000; // 60s
export const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100%
export const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25%
export const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0%
export const OFFLINE_FLOOR = 0.25; // plancher decay
// --- Anti-cheat ---
export const MAX_PRODUCTION_PER_SECOND = 750_000;
export const CHEAT_MARGIN = 1.1;
// --- Save version ---
export const CURRENT_SAVE_VERSION = 2;

View File

@@ -0,0 +1,97 @@
// cosmetics.ts — Système cosmétique (récompenses achievements + prestige)
import type { GameState } from "./economy";
import { ACHIEVEMENTS } from "../data/achievements";
export type CosmeticSlot = "hat" | "eyes" | "body" | "tail" | "accessory";
export interface Cosmetic {
id: string;
name: string;
slot: CosmeticSlot;
svg: string; // chemin vers le SVG overlay (/svg/cosmetics/...)
source: "achievement" | "prestige";
sourceId: string; // achievement id ou "prestige_N"
description: string;
}
export interface CosmeticState {
inventory: string[]; // ids des cosmétiques débloqués
equipped: Partial<Record<CosmeticSlot, string>>; // slot → cosmetic id
}
export const DEFAULT_COSMETIC_STATE: CosmeticState = {
inventory: [],
equipped: {},
};
// --- Catalogue des cosmétiques ---
export const COSMETICS: Cosmetic[] = [
// Hat
{ id: "crown", name: "Couronne Ancestrale", slot: "hat", svg: "/svg/cosmetics/crown.svg", source: "prestige", sourceId: "prestige_10", description: "10 prestiges — la royauté du marais" },
{ id: "cap_swamp", name: "Casquette du Marais", slot: "hat", svg: "/svg/cosmetics/cap-swamp.svg", source: "achievement", sourceId: "industriel", description: "10 générateurs au total" },
// Eyes
{ id: "glasses_savant", name: "Lunettes du Savant", slot: "eyes", svg: "/svg/cosmetics/glasses-savant.svg", source: "prestige", sourceId: "prestige_5", description: "5 prestiges — la sagesse" },
{ id: "mask_frog", name: "Masque Grenouille", slot: "eyes", svg: "/svg/cosmetics/mask-frog.svg", source: "achievement", sourceId: "empire", description: "1M têtards — le regard de l'empire" },
// Body
{ id: "cape_algae", name: "Cape d'Algues", slot: "body", svg: "/svg/cosmetics/cape-algae.svg", source: "prestige", sourceId: "prestige_25", description: "25 prestiges — tissée par le marais" },
{ id: "armor_scales", name: "Armure d'Écailles", slot: "body", svg: "/svg/cosmetics/armor-scales.svg", source: "achievement", sourceId: "tycoon", description: "100 générateurs — blindage total" },
// Tail
{ id: "flame_tail", name: "Queue Enflammée", slot: "tail", svg: "/svg/cosmetics/flame-tail.svg", source: "prestige", sourceId: "prestige_50", description: "50 prestiges — la traîne de feu" },
{ id: "ribbon", name: "Ruban du Nouveau-Né", slot: "tail", svg: "/svg/cosmetics/ribbon.svg", source: "achievement", sourceId: "first_prestige", description: "Premier prestige — le début de tout" },
// Accessory
{ id: "aura_swamp", name: "Aura du Marais", slot: "accessory", svg: "/svg/aura-swamp.svg", source: "achievement", sourceId: "veteran", description: "5 prestiges — l'aura des anciens" },
{ id: "particles_gold", name: "Particules Dorées", slot: "accessory", svg: "/svg/cosmetics/particles-gold.svg", source: "prestige", sourceId: "prestige_3", description: "3 prestiges — poussière d'étoiles" },
];
// --- Fonctions cosmétiques ---
// Vérifie si un cosmétique devrait être débloqué
export function shouldUnlockCosmetic(cosmetic: Cosmetic, state: GameState): boolean {
if (cosmetic.source === "prestige") {
const tier = parseInt(cosmetic.sourceId.replace("prestige_", ""), 10);
return state.prestigeCount >= tier;
}
if (cosmetic.source === "achievement") {
const achievement = ACHIEVEMENTS.find((a) => a.id === cosmetic.sourceId);
return achievement ? achievement.check(state) : false;
}
return false;
}
// Calcule les cosmétiques nouvellement débloqués (pas encore dans l'inventaire)
export function computeNewUnlocks(state: GameState, cosmeticState: CosmeticState): string[] {
return COSMETICS
.filter((c) => !cosmeticState.inventory.includes(c.id) && shouldUnlockCosmetic(c, state))
.map((c) => c.id);
}
// Équiper un cosmétique (retourne le nouvel état)
export function equipCosmetic(cosmeticState: CosmeticState, cosmeticId: string): CosmeticState {
const cosmetic = COSMETICS.find((c) => c.id === cosmeticId);
if (!cosmetic) return cosmeticState;
if (!cosmeticState.inventory.includes(cosmeticId)) return cosmeticState;
return {
...cosmeticState,
equipped: { ...cosmeticState.equipped, [cosmetic.slot]: cosmeticId },
};
}
// Déséquiper un slot
export function unequipSlot(cosmeticState: CosmeticState, slot: CosmeticSlot): CosmeticState {
const { [slot]: _, ...rest } = cosmeticState.equipped;
return { ...cosmeticState, equipped: rest };
}
// Ajouter des cosmétiques à l'inventaire
export function addToInventory(cosmeticState: CosmeticState, ids: string[]): CosmeticState {
const newIds = ids.filter((id) => !cosmeticState.inventory.includes(id));
if (newIds.length === 0) return cosmeticState;
return { ...cosmeticState, inventory: [...cosmeticState.inventory, ...newIds] };
}

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: [],
};

View File

@@ -0,0 +1,142 @@
// migrateSave.ts — Migration lazy des saves entre versions
// Appliqué au chargement (frontend + backend). Jamais de migration en DB.
// Chaque sprint ajoute un step (v2→v3, etc.)
import { CURRENT_SAVE_VERSION } from "./balance";
import type { GameState } from "./economy";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
/**
* Détecte la version d'une save et applique les migrations nécessaires.
* Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet).
* Sortie : GameState conforme à la version courante.
*/
export function migrateSave(raw: Record<string, unknown>): GameState {
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
let state = raw as Record<string, unknown>;
if (version < 2) {
state = migrateV1toV2(state);
}
// Futures migrations :
// if (version < 3) state = migrateV2toV3(state);
return state as unknown as GameState;
}
/**
* v1 → v2 : Sprint 2 → Sprint 3
* - Ajoute saveVersion
* - Ajoute runStats (vide)
* - Ajoute freeResetAvailable + extraResetsUsed
* - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants)
* - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline)
*/
function migrateV1toV2(raw: Record<string, unknown>): Record<string, unknown> {
const state = { ...raw };
// saveVersion
state.saveVersion = 2;
// RunStats (nouveau Sprint 3)
if (!state.runStats) {
state.runStats = {
startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(),
tadpolesProduced: 0,
bestRun: null,
};
}
// Reset arbre : 1 gratuit par prestige
if (typeof state.freeResetAvailable !== "boolean") {
state.freeResetAvailable = true;
}
if (typeof state.extraResetsUsed !== "number") {
state.extraResetsUsed = 0;
}
// Milestones (Sprint 3)
if (!Array.isArray(state.claimedMilestones)) {
state.claimedMilestones = [];
}
// Backfill champs Sprint 2 potentiellement manquants
if (!state.lastOnline) state.lastOnline = state.lastTick;
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
state.cosmeticEquipped = {};
}
// Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux
state.evolutionTree = mergeEvolutionTree(
state.evolutionTree as Array<Record<string, unknown>> | undefined
);
// Merge générateurs : conserver owned + ajouter les potentiels nouveaux
state.generators = mergeGenerators(
state.generators as Array<Record<string, unknown>> | undefined
);
return state;
}
/**
* Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE.
* - Nœuds existants : conserve unlocked state
* - Nœuds nouveaux : ajoutés avec unlocked: false
* - Nœuds supprimés du default : retirés (forward compat)
*/
function mergeEvolutionTree(
savedTree: Array<Record<string, unknown>> | undefined
): typeof DEFAULT_EVOLUTION_TREE {
if (!savedTree || !Array.isArray(savedTree)) {
return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n }));
}
const savedById = new Map(
savedTree.map((n) => [n.id as string, n])
);
return DEFAULT_EVOLUTION_TREE.map((defaultNode) => {
const saved = savedById.get(defaultNode.id);
if (saved) {
// Conserver l'état unlocked, tout le reste vient du default
// (permet de corriger des valeurs rebalancées sans casser les saves)
return {
...defaultNode,
unlocked: saved.unlocked === true,
};
}
// Nouveau nœud — ajouté verrouillé
return { ...defaultNode };
});
}
/**
* Merge les générateurs sauvegardés avec DEFAULT_GENERATORS.
* Conserve le owned count, met à jour les stats de base.
*/
function mergeGenerators(
savedGens: Array<Record<string, unknown>> | undefined
): typeof DEFAULT_GENERATORS {
if (!savedGens || !Array.isArray(savedGens)) {
return DEFAULT_GENERATORS.map((g) => ({ ...g }));
}
const savedById = new Map(
savedGens.map((g) => [g.id as string, g])
);
return DEFAULT_GENERATORS.map((defaultGen) => {
const saved = savedById.get(defaultGen.id);
if (saved) {
return {
...defaultGen,
owned: typeof saved.owned === "number" ? saved.owned : 0,
};
}
return { ...defaultGen };
});
}

View File

@@ -0,0 +1,219 @@
// achievements.ts — Milestones Clickerz basés sur le GameState réel
import type { GameState } from "../core/economy";
export interface Achievement {
id: string;
name: string;
description: string;
icon: string;
check: (state: GameState) => boolean;
}
function totalGeneratorsOwned(state: GameState): number {
return state.generators.reduce((sum, g) => sum + g.owned, 0);
}
function hasGenerator(state: GameState, genId: string): boolean {
return state.generators.some((g) => g.id === genId && g.owned > 0);
}
function hasEvolutionNode(state: GameState, nodeId: string): boolean {
return state.evolutionTree.some((n) => n.id === nodeId && n.unlocked);
}
export const ACHIEVEMENTS: Achievement[] = [
// --- Paliers de ressources ---
{
id: "first_tadpole",
name: "Premier Têtard",
description: "Faire éclore son tout premier têtard.",
icon: "🥚",
check: (s) => s.resources >= 1 || s.lifetimeTadpoles >= 1,
},
{
id: "colony",
name: "Colonie",
description: "Atteindre 100 têtards.",
icon: "🌱",
check: (s) => s.resources >= 100 || s.lifetimeTadpoles >= 100,
},
{
id: "village",
name: "Village",
description: "Atteindre 1 000 têtards.",
icon: "🏘️",
check: (s) => s.resources >= 1_000 || s.lifetimeTadpoles >= 1_000,
},
{
id: "city",
name: "Cité",
description: "Atteindre 10 000 têtards.",
icon: "🏙️",
check: (s) => s.resources >= 10_000 || s.lifetimeTadpoles >= 10_000,
},
{
id: "metropolis",
name: "Métropole",
description: "Atteindre 100 000 têtards.",
icon: "🌆",
check: (s) => s.resources >= 100_000 || s.lifetimeTadpoles >= 100_000,
},
{
id: "empire",
name: "Empire du Marais",
description: "Atteindre 1 000 000 de têtards.",
icon: "👑",
check: (s) => s.resources >= 1_000_000 || s.lifetimeTadpoles >= 1_000_000,
},
// --- Générateurs ---
{
id: "first_nid",
name: "Nidificateur",
description: "Construire son premier Nid.",
icon: "🪹",
check: (s) => hasGenerator(s, "nid"),
},
{
id: "first_mare",
name: "Batracien",
description: "Aménager sa première Mare.",
icon: "💧",
check: (s) => hasGenerator(s, "mare"),
},
{
id: "first_marecage",
name: "Marécageux",
description: "S'enfoncer dans son premier Marécage.",
icon: "🌿",
check: (s) => hasGenerator(s, "marecage"),
},
{
id: "first_etang",
name: "Gardien de l'Étang",
description: "Découvrir un Étang Ancien.",
icon: "🏛️",
check: (s) => hasGenerator(s, "etang"),
},
{
id: "first_lac",
name: "Seigneur du Lac",
description: "Accéder au Lac Mystique.",
icon: "🔮",
check: (s) => hasGenerator(s, "lac"),
},
{
id: "industriel",
name: "Industriel",
description: "Posséder 10 générateurs au total.",
icon: "🏭",
check: (s) => totalGeneratorsOwned(s) >= 10,
},
{
id: "magnate",
name: "Magnate",
description: "Posséder 50 générateurs au total.",
icon: "💎",
check: (s) => totalGeneratorsOwned(s) >= 50,
},
{
id: "tycoon",
name: "Tycoon du Marais",
description: "Posséder 100 générateurs au total.",
icon: "🐸",
check: (s) => totalGeneratorsOwned(s) >= 100,
},
// --- Prestige ---
{
id: "first_prestige",
name: "Nouvelle Génération",
description: "Effectuer son premier prestige.",
icon: "🧬",
check: (s) => s.prestigeCount >= 1,
},
{
id: "veteran",
name: "Vétéran",
description: "Atteindre 5 prestiges.",
icon: "⭐",
check: (s) => s.prestigeCount >= 5,
},
{
id: "legend",
name: "Légende du Marais",
description: "Atteindre 10 prestiges.",
icon: "🏆",
check: (s) => s.prestigeCount >= 10,
},
// --- ADN & Évolution ---
{
id: "first_dna",
name: "ADN Ancestral",
description: "Accumuler son premier ADN.",
icon: "🧪",
check: (s) => s.ancestralDna >= 1,
},
{
id: "first_evolution",
name: "Première Mutation",
description: "Débloquer la Ponte Améliorée.",
icon: "🦎",
check: (s) => hasEvolutionNode(s, "ponte_amelioree"),
},
{
id: "full_tree",
name: "Évolution Complète",
description: "Débloquer un nœud dans chaque branche de l'arbre.",
icon: "🌳",
check: (s) => {
const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch));
return branches.size >= 3;
},
},
// --- Easter eggs & humour ---
{
id: "chuck_norris",
name: "Blague",
description: "Quand Chuck Norris fait un programme, il installe les modules, code et vend le programme... ensuite il demande à quoi il doit servir.",
icon: "🤜",
check: (s) => s.lifetimeTadpoles >= 42,
},
{
id: "patience",
name: "Patience de Têtard",
description: "Un têtard ne devient pas grenouille en un jour. Toi non plus visiblement.",
icon: "🐌",
check: (s) => hasGenerator(s, "nid") && s.resources < 50,
},
{
id: "brain_powered",
name: "Powered by Brain",
description: "Le Brain a codé ce succès avant de savoir pourquoi. Classique.",
icon: "🧠",
check: (s) => s.prestigeCount >= 1 && totalGeneratorsOwned(s) >= 10,
},
{
id: "marecage_addict",
name: "Addict au Marécage",
description: "T'as 10 marécages. Tu sens un peu la vase mais on respecte l'engagement.",
icon: "🫠",
check: (s) => s.generators.find((g) => g.id === "marecage")?.owned >= 10,
},
{
id: "overkill",
name: "Overkill",
description: "25 Nids. T'aurais pu investir dans un Lac, mais non.",
icon: "😅",
check: (s) => s.generators.find((g) => g.id === "nid")?.owned >= 25,
},
{
id: "symbiose_joke",
name: "Le Cercle de la Vie",
description: "Symbiose activée. Même Mufasa serait fier.",
icon: "🦁",
check: (s) => hasEvolutionNode(s, "symbiose_algale"),
},
];

View File

@@ -0,0 +1,76 @@
// prestigeMilestones.ts — Paliers de prestige (Sprint 3)
// 8 paliers : cosmétiques exclusifs + bonus gameplay légers
export type MilestoneRewardType = "cosmetic" | "bonus" | "title";
export interface PrestigeMilestone {
id: string;
threshold: number; // nombre de prestiges requis
name: string;
description: string;
reward: MilestoneReward;
}
export type MilestoneReward =
| { type: "cosmetic"; cosmeticId: string; label: string }
| { type: "bonus"; effect: string; value: number; label: string }
| { type: "title"; title: string; label: string };
export const PRESTIGE_MILESTONES: PrestigeMilestone[] = [
{
id: "milestone_1",
threshold: 1,
name: "Premiere Generation",
description: "Premier prestige accompli",
reward: { type: "cosmetic", cosmeticId: "ribbon", label: "Ruban queue" },
},
{
id: "milestone_3",
threshold: 3,
name: "Gardien Recurrent",
description: "3 prestiges — la perseverance paie",
reward: { type: "title", title: "Gardien Recurrent", label: "Titre exclusif" },
},
{
id: "milestone_5",
threshold: 5,
name: "Nid Offert",
description: "5 prestiges — un coup de pouce au depart",
reward: { type: "bonus", effect: "start_nid", value: 1, label: "1 Nid gratuit au depart" },
},
{
id: "milestone_10",
threshold: 10,
name: "Tetard Ancestral",
description: "10 prestiges — la lignee s'affirme",
reward: { type: "cosmetic", cosmeticId: "crown", label: "Couronne doree + skin Ancestral" },
},
{
id: "milestone_15",
threshold: 15,
name: "Marais Fidele",
description: "15 prestiges — le marais te reconnait",
reward: { type: "bonus", effect: "offline_cap_perm", value: 0.05, label: "+5% offline cap permanent" },
},
{
id: "milestone_25",
threshold: 25,
name: "Gardien Emerite",
description: "25 prestiges — tissu d'algues ancestrales",
reward: { type: "cosmetic", cosmeticId: "cape_algae", label: "Cape d'algues ancestrales" },
},
{
id: "milestone_50",
threshold: 50,
name: "Legende du Marais",
description: "50 prestiges — la legende est toi",
reward: { type: "cosmetic", cosmeticId: "flame_tail", label: "Queue enflamee + particules dorees" },
},
{
id: "milestone_100",
threshold: 100,
name: "Tetard Primordial",
description: "100 prestiges — retour aux origines",
reward: { type: "cosmetic", cosmeticId: "primordial_body", label: "Skin Tetard Primordial (full set)" },
},
];

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,83 +0,0 @@
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
export async function buildAuthUrl(redirectUri, provider, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return {
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
verifier,
};
}
export async function exchangeCode(code, verifier, redirectUri, clientId = OAUTH_CLIENT_ID) {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json();
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
export function saveVerifier(verifier) {
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier() {
return localStorage.getItem(SESSION_KEY_VERIFIER);
}
export function clearVerifier() {
localStorage.removeItem(SESSION_KEY_VERIFIER);
}

89
Frontend/src/lib/oauth.ts Normal file
View File

@@ -0,0 +1,89 @@
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
export async function buildAuthUrl(
redirectUri: string,
provider: string,
scope = 'openid profile email',
clientId = OAUTH_CLIENT_ID
) {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return { url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, verifier };
}
export async function exchangeCode(
code: string,
verifier: string,
redirectUri: string,
clientId = OAUTH_CLIENT_ID
) {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json();
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
export function saveVerifier(verifier: string) {
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier(): string | null {
return localStorage.getItem(SESSION_KEY_VERIFIER);
}
export function clearVerifier() {
localStorage.removeItem(SESSION_KEY_VERIFIER);
}

View File

@@ -0,0 +1,122 @@
// save-sync.ts — Auto-save game state to backend every 30s
// Server = authority. NEVER save before server state is loaded (ready guard).
import { gameStore } from '$lib/stores/game.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { migrateSave } from '$lib/core/migrateSave';
import type { GameState } from '$lib/core/economy';
const SAVE_INTERVAL_MS = 30_000;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(`${BACKEND_URL}/api${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
if (!res.ok) {
console.warn(`[SaveSync] ${path} failed:`, res.status);
return null;
}
return res.json();
}
let lastSave: string | null = null;
let loaded = false;
let saveInterval: ReturnType<typeof setInterval> | null = null;
export async function saveToServer() {
if (!authStore.user || !gameStore.ready) return;
const result = await apiRequest('/save', {
method: 'POST',
body: JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.playSeconds,
}),
});
if (result?.lastSave) {
lastSave = result.lastSave;
}
}
export async function loadFromServer(): Promise<boolean> {
if (loaded || !authStore.user) {
if (!authStore.user) loaded = true;
return false;
}
loaded = true;
try {
const data = await apiRequest('/save');
if (data?.gameState) {
const migrated = migrateSave(data.gameState);
gameStore.loadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
return true;
}
console.info('[SaveSync] No server save found');
return false;
} catch {
console.warn('[SaveSync] Server unreachable');
return false;
}
}
export function startAutoSave() {
stopAutoSave();
saveInterval = setInterval(() => {
if (authStore.user && gameStore.ready) saveToServer();
}, SAVE_INTERVAL_MS);
}
export function stopAutoSave() {
if (saveInterval) {
clearInterval(saveInterval);
saveInterval = null;
}
}
export function setupVisibilitySync() {
if (typeof window === 'undefined') return;
window.addEventListener('focus', () => {
if (!authStore.user) return;
setTimeout(async () => {
const data = await apiRequest('/save');
if (data?.gameState && data.lastSave) {
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
const migrated = migrateSave(data.gameState);
gameStore.loadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Reloaded from server on focus');
}
}
}, 500);
});
window.addEventListener('blur', () => {
if (authStore.user && gameStore.ready) saveToServer();
});
window.addEventListener('beforeunload', () => {
if (!authStore.user || !gameStore.ready) return;
const payload = JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.playSeconds,
});
fetch(`${BACKEND_URL}/api/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => {});
});
}
export function resetSaveSync() {
loaded = false;
lastSave = null;
}

View File

@@ -0,0 +1,64 @@
// auth.svelte.ts — Auth store (Svelte 5 runes)
// Cookie-based auth with SuperOAuth PKCE
import { apiFetch } from '$lib/api';
export interface User {
id: number;
nickname: string;
avatar_url?: string;
[key: string]: unknown;
}
let user = $state<User | null>(null);
let loading = $state(true);
async function refresh() {
try {
const data = await apiFetch('/auth/me');
user = data as User;
} catch {
user = null;
}
}
async function init() {
await refresh();
loading = false;
// Listen for expired session
if (typeof window !== 'undefined') {
window.addEventListener('auth:expired', () => {
user = null;
});
}
}
async function logout() {
try {
await apiFetch('/auth/logout', { method: 'POST' });
} catch {
// ignore
}
user = null;
}
async function editUser(updatedFields: Record<string, unknown>) {
if (!user) return;
const data = await apiFetch(`/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(updatedFields),
});
if (data?.user) {
user = { ...user, ...data.user };
}
}
export const authStore = {
get user() { return user; },
get loading() { return loading; },
init,
refresh,
logout,
editUser,
};

View File

@@ -0,0 +1,309 @@
// game.svelte.ts — Game store (Svelte 5 runes)
// Server = authority. localStorage = fallback guest only.
import {
type GameState,
DEFAULT_STATE,
applyIdleGains,
applyClick,
getClickGain,
getAutoClicksPerSecond,
buyGenerator,
buyEvolutionNode,
resetEvolutionTree,
canResetTree,
upgradeConvergence,
claimMilestone as claimMilestoneFn,
applyPrestige,
canPrestige as canPrestigeCheck,
totalProductionPerSecond,
generatorCost as genCost,
computeOfflineGains,
} from '$lib/core/economy';
import { migrateSave } from '$lib/core/migrateSave';
import { toast } from './toast.svelte';
import {
computeNewUnlocks,
equipCosmetic as equipCosmeticFn,
unequipSlot as unequipSlotFn,
addToInventory,
type CosmeticSlot,
} from '$lib/core/cosmetics';
const SAVE_KEY = 'clickerz_state';
const OFFLINE_THRESHOLD = 60_000;
// --- Offline report ---
export interface OfflineReport {
wasOffline: boolean;
duration: number;
gains: number;
efficiency: number;
}
// --- Reactive state (Svelte 5 runes) ---
let state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
let playSeconds = $state(0);
let ready = $state(false);
let offlineReport = $state<OfflineReport | null>(null);
let showPrestigeScreen = $state(false);
let lastClickGain = $state(0);
let lastClickDouble = $state(false);
let lastClickCrit = $state(false);
let canPrestige = $state(false);
let productionPerSecond = $state(0);
// --- Local storage ---
function loadLocalState(): GameState {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
const parsed = JSON.parse(raw);
const saved = migrateSave(parsed);
return applyIdleGains(saved, Date.now());
} catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
}
}
function saveLocal(s: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
}
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) {
const hydrated = applyIdleGains(saved, now);
return { state: { ...hydrated, lastOnline: now }, report: null };
}
const gains = computeOfflineGains(saved, now);
const pps = totalProductionPerSecond(saved);
const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
};
return {
state: hydrated,
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
};
}
// --- Derived ---
function updateDerived() {
canPrestige = canPrestigeCheck(state);
productionPerSecond = totalProductionPerSecond(state);
}
// --- Actions ---
function tick() {
if (!ready) return;
const now = Date.now();
const updated = applyIdleGains(state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
const newCos = addToInventory(cosState, newUnlocks);
updated.cosmeticInventory = newCos.inventory;
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
}
}
saveLocal(updated);
state = updated;
playSeconds += 1;
updateDerived();
}
function click() {
if (!ready) return;
const result = applyClick(applyIdleGains(state, Date.now()));
saveLocal(result.state);
state = result.state;
lastClickGain = result.gain;
lastClickDouble = result.isDouble;
lastClickCrit = result.isCrit;
updateDerived();
}
function buy(genId: string) {
if (!ready) return;
const withIdle = applyIdleGains(state, Date.now());
const updated = buyGenerator(withIdle, genId);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
}
function buyNode(nodeId: string) {
if (!ready) return;
const updated = buyEvolutionNode(state, nodeId);
if (!updated) return;
const node = updated.evolutionTree.find((n) => n.id === nodeId);
saveLocal(updated);
if (node?.capstone) {
toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
}
state = updated;
updateDerived();
}
function prestige() {
if (!ready) return;
if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(state);
saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
state = updated;
showPrestigeScreen = false;
updateDerived();
}
function equipCosmetic(cosmeticId: string) {
if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ...state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
}
function unequipCosmetic(slot: CosmeticSlot) {
if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
const newState = { ...state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
}
function doResetTree() {
if (!ready) return;
if (!canResetTree(state)) return;
const updated = resetEvolutionTree(state);
saveLocal(updated);
state = updated;
updateDerived();
}
function doUpgradeConvergence() {
if (!ready) return;
const updated = upgradeConvergence(state);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
}
function doClaimMilestone(milestoneId: string) {
if (!ready) return;
const updated = claimMilestoneFn(state, milestoneId);
if (!updated) return;
saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000);
state = updated;
}
function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
state = fresh;
playSeconds = 0;
ready = true;
offlineReport = null;
canPrestige = false;
productionPerSecond = 0;
}
function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state);
state = result.state;
ready = true;
offlineReport = result.report;
updateDerived();
}
function initGuest() {
const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state);
state = result.state;
ready = true;
offlineReport = result.report;
updateDerived();
}
function dismissOfflineReport() {
offlineReport = null;
}
function openPrestige() {
showPrestigeScreen = true;
}
function closePrestige() {
showPrestigeScreen = false;
}
// --- Public API (single object export for ergonomic access) ---
export const gameStore = {
get state() { return state; },
get playSeconds() { return playSeconds; },
get ready() { return ready; },
get offlineReport() { return offlineReport; },
get showPrestigeScreen() { return showPrestigeScreen; },
get lastClickGain() { return lastClickGain; },
get lastClickDouble() { return lastClickDouble; },
get lastClickCrit() { return lastClickCrit; },
get canPrestige() { return canPrestige; },
get productionPerSecond() { return productionPerSecond; },
tick,
click,
buy,
buyNode,
prestige,
equipCosmetic,
unequipCosmetic,
resetTree: doResetTree,
upgradeConvergence: doUpgradeConvergence,
claimMilestone: doClaimMilestone,
reset,
loadFromServer,
initGuest,
dismissOfflineReport,
openPrestige,
closePrestige,
generatorCost: genCost,
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, state.evolutionTree),
getClickGain: () => getClickGain(state),
};

View File

@@ -0,0 +1,33 @@
// toast.svelte.ts — Toast notification system (Svelte 5 runes)
export type ToastVariant = 'success' | 'info' | 'reward' | 'warning';
export interface Toast {
id: number;
message: string;
variant: ToastVariant;
duration: number;
}
let nextId = 0;
let toasts = $state<Toast[]>([]);
export function getToasts() {
return toasts;
}
export function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
const id = nextId++;
toasts = [...toasts, { id, message, variant, duration }];
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
}, duration);
}
export function removeToast(id: number) {
toasts = toasts.filter((t) => t.id !== id);
}
// Shorthand — usable from anywhere (stores, lib, components)
export const toast = addToast;

View File

@@ -0,0 +1,9 @@
// formatNumber.ts — Affichage formaté des grands nombres
export function formatNumber(n: number): string {
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}T`;
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return Math.floor(n).toString();
}