fix: refactor store to direct $state exports + Object.assign mutation
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s

Svelte 5 can't export reassigned $state — use const $state + Object.assign.
All components now import state/actions directly (no gameStore wrapper).
Deep reactivity works: evolutionTree nodes, generators, cosmetics all tracked.
This commit is contained in:
2026-03-28 20:23:57 +01:00
parent ce38975c10
commit 10ff2d32f5
16 changed files with 214 additions and 252 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { state, getProductionPerSecond, getCurrentClickGain } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
</script>
@@ -7,23 +7,23 @@
<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>
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(getProductionPerSecond())}</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>
<span class="gp-value text-[0.8rem]!">{formatNumber(getCurrentClickGain())}</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>
<span class="gp-value text-[0.8rem]!">x{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>
<span class="gp-value gp-accent-purple text-[0.8rem]!">{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>
<span class="gp-value text-[0.8rem]!">{state.prestigeCount}</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, equipCosmetic, unequipCosmetic } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
import CollapsiblePanel from './CollapsiblePanel.svelte';
@@ -13,8 +13,8 @@
};
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
let inventory = $derived(gameStore.state.cosmeticInventory);
let equipped = $derived(gameStore.state.cosmeticEquipped);
let inventory = $derived(state.cosmeticInventory);
let equipped = $derived(state.cosmeticEquipped);
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
</script>
@@ -40,7 +40,7 @@
<span class="gp-label">{cos.description}</span>
</div>
<button
onclick={() => isEquipped ? gameStore.unequipCosmetic(slot) : gameStore.equipCosmetic(cos.id)}
onclick={() => isEquipped ? unequipCosmetic(slot) : equipCosmetic(cos.id)}
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
>
{isEquipped ? 'Retirer' : 'Equiper'}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { state, buyNode, doResetTree, doUpgradeConvergence } from '$lib/stores/game.svelte';
import {
canBuyEvolutionNode,
getSpentDna,
@@ -44,14 +44,14 @@
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 branchNodes = $derived(state.evolutionTree.filter((n) => n.branch === activeBranch));
let spentDna = $derived(getSpentDna(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));
let resetCost = $derived(getTreeResetCost(state));
let canReset = $derived(canResetTree(state));
let conv = $derived(state.evolutionTree.find((n) => n.id === 'convergence'));
let canBuyConv = $derived(canBuyEvolutionNode(state, 'convergence'));
let canUpgradeConv = $derived(canUpgradeConvergence(state));
function handleReset() {
if (!canReset) return;
@@ -59,7 +59,7 @@
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();
if (confirmed) doResetTree();
}
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
@@ -70,13 +70,13 @@
}
</script>
{#if gameStore.state.prestigeCount >= 1}
{#if 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>
<span class="gp-value gp-accent-amber">{formatNumber(state.ancestralDna)} ADN</span>
{#if hasUnlocked}
<button
onclick={handleReset}
@@ -108,8 +108,8 @@
<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 isExcluded = node.exclusive_with ? (state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
{@const canBuy = canBuyEvolutionNode(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">
@@ -132,7 +132,7 @@
{:else}
<button
disabled={!canBuy}
onclick={() => gameStore.buyNode(node.id)}
onclick={() => buyNode(node.id)}
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
@@ -164,7 +164,7 @@
{#if tier < maxTier}
<button
disabled={!canUpgradeConv}
onclick={() => gameStore.upgradeConvergence()}
onclick={() => doUpgradeConvergence()}
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
>
{canUpgradeConv ? `Evoluer Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
@@ -180,7 +180,7 @@
</div>
<button
disabled={!canBuyConv}
onclick={() => gameStore.buyNode('convergence')}
onclick={() => buyNode('convergence')}
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{conv.cost}

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, buy, getProductionPerSecond, getGeneratorCostWithTree } 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"
badge="{formatNumber(getProductionPerSecond())}/s"
accentClass=""
>
{#each gameStore.state.generators as gen, i}
{@const cost = gameStore.generatorCostWithTree(gen)}
{@const canAfford = gameStore.state.resources >= cost}
{#each state.generators as gen, i}
{@const cost = getGeneratorCostWithTree(gen)}
{@const canAfford = state.resources >= cost}
{@const currentProd = gen.baseProduction * gen.owned}
<div
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
@@ -39,7 +39,7 @@
</span>
</div>
<button
onclick={() => gameStore.buy(gen.id)}
onclick={() => buy(gen.id)}
disabled={!canAfford}
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { state } 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 threshold = $derived(getPrestigeThreshold(state));
let progress = $derived(Math.min(state.resources / threshold, 1));
let progressPercent = $derived((progress * 100).toFixed(1));
let remaining = $derived(Math.max(threshold - gameStore.state.resources, 0));
let remaining = $derived(Math.max(threshold - 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>
<span class="gp-label">{formatNumber(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>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { fly, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, doClaimMilestone } 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 claimable = $derived(getClaimableMilestones(state));
let nextMilestone = $derived(getNextMilestone(state));
let claimed = $derived(state.claimedMilestones ?? []);
let totalClaimed = $derived(claimed.length);
</script>
{#if gameStore.state.prestigeCount >= 1}
{#if state.prestigeCount >= 1}
<CollapsiblePanel
title="Milestones"
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
@@ -29,7 +29,7 @@
<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">
<button onclick={() => doClaimMilestone(m.id)} class="gp-btn gp-btn--buy">
Claim
</button>
</div>
@@ -38,11 +38,11 @@
{/if}
{#if nextMilestone}
{@const progressPct = Math.min((gameStore.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
{@const progressPct = Math.min((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>
<span class="gp-label">{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>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fade, fly, scale } from 'svelte/transition';
import { backOut, quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { refs, dismissOfflineReport } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
function formatDuration(ms: number): string {
@@ -12,16 +12,16 @@
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.offlineReport) gameStore.dismissOfflineReport(); }} />
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && refs.offlineReport) dismissOfflineReport(); }} />
{#if gameStore.offlineReport}
{#if refs.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()}
onclick={() => dismissOfflineReport()}
>
<div
class="gp max-w-sm w-full mx-4 text-center"
@@ -32,7 +32,7 @@
<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>
Absent pendant <span class="gp-accent-green">{formatDuration(refs.offlineReport.duration)}</span>
</p>
</div>
@@ -41,17 +41,17 @@
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
+{formatNumber(refs.offlineReport.gains)} tetards
</p>
</div>
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
Efficacite : {Math.round(gameStore.offlineReport.efficiency * 100)}%
Efficacite : {Math.round(refs.offlineReport.efficiency * 100)}%
</p>
<button
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
onclick={() => gameStore.dismissOfflineReport()}
onclick={() => dismissOfflineReport()}
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
>
Continuer

View File

@@ -1,26 +1,26 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, getCanPrestige, openPrestige } 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 baseDna = $derived(computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(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));
let threshold = $derived(getPrestigeThreshold(state));
let progress = $derived(Math.min(state.lifetimeTadpoles / threshold * 100, 100));
</script>
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
{#if gameStore.canPrestige}
{#if getCanPrestige()}
<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!">
<button onclick={() => openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
Nouvelle Generation
</button>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, refs, prestige, closePrestige } from '$lib/stores/game.svelte';
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
@@ -15,24 +15,24 @@
return `${seconds}s`;
}
let baseDna = $derived(computePrestigeDna(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
let baseDna = $derived(computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(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 threshold = $derived(getPrestigeThreshold(state));
let canPrestige = $derived(state.lifetimeTadpoles >= threshold);
let runDuration = $derived(Date.now() - state.runStats.startedAt);
let bestRun = $derived(state.runStats.bestRun);
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
let isBestTadpoles = $derived(!bestRun || gameStore.state.lifetimeTadpoles > bestRun.tadpoles);
let isBestTadpoles = $derived(!bestRun || state.lifetimeTadpoles > bestRun.tadpoles);
function handlePrestige() {
if (canPrestige) gameStore.prestige();
if (canPrestige) prestige();
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.showPrestigeScreen) gameStore.closePrestige(); }} />
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && refs.showPrestigeScreen) closePrestige(); }} />
{#if gameStore.showPrestigeScreen}
{#if refs.showPrestigeScreen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
@@ -48,7 +48,7 @@
<!-- 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>
<p class="gp-label mt-1">Generation #{state.prestigeCount + 1}</p>
</div>
<div class="gp-sep"></div>
@@ -68,7 +68,7 @@
{#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>
<span class="gp-label mt-1">Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN</span>
</div>
<div class="gp-sep"></div>
@@ -85,7 +85,7 @@
<div class="flex justify-between">
<span class="gp-label">Tetards produits</span>
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
{formatNumber(gameStore.state.lifetimeTadpoles)}
{formatNumber(state.lifetimeTadpoles)}
{#if isBestTadpoles && bestRun}{/if}
</span>
</div>
@@ -141,7 +141,7 @@
<!-- Actions -->
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
<button
onclick={() => gameStore.closePrestige()}
onclick={() => 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);"
>
@@ -153,7 +153,7 @@
</button>
{:else}
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
{formatNumber(threshold - gameStore.state.lifetimeTadpoles)} manquants
{formatNumber(threshold - state.lifetimeTadpoles)} manquants
</button>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { state } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
@@ -7,7 +7,7 @@
let overlays = $derived(
SLOT_ORDER
.map((slot) => {
const cosId = gameStore.state.cosmeticEquipped[slot];
const cosId = state.cosmeticEquipped[slot];
if (!cosId) return null;
return COSMETICS.find((c) => c.id === cosId) ?? null;
})

View File

@@ -1,7 +1,7 @@
// 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 { state, refs, loadFromServer as storeLoadFromServer, initGuest } 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';
@@ -27,12 +27,12 @@ let loaded = false;
let saveInterval: ReturnType<typeof setInterval> | null = null;
export async function saveToServer() {
if (!authStore.user || !gameStore.ready) return;
if (!authStore.user || !refs.ready) return;
const result = await apiRequest('/save', {
method: 'POST',
body: JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.playSeconds,
gameState: state,
playTimeSeconds: refs.playSeconds,
}),
});
if (result?.lastSave) {
@@ -51,7 +51,7 @@ export async function loadFromServer(): Promise<boolean> {
const data = await apiRequest('/save');
if (data?.gameState) {
const migrated = migrateSave(data.gameState);
gameStore.loadFromServer(migrated);
storeLoadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
return true;
@@ -67,7 +67,7 @@ export async function loadFromServer(): Promise<boolean> {
export function startAutoSave() {
stopAutoSave();
saveInterval = setInterval(() => {
if (authStore.user && gameStore.ready) saveToServer();
if (authStore.user && refs.ready) saveToServer();
}, SAVE_INTERVAL_MS);
}
@@ -88,7 +88,7 @@ export function setupVisibilitySync() {
if (data?.gameState && data.lastSave) {
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
const migrated = migrateSave(data.gameState);
gameStore.loadFromServer(migrated);
storeLoadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Reloaded from server on focus');
}
@@ -97,14 +97,14 @@ export function setupVisibilitySync() {
});
window.addEventListener('blur', () => {
if (authStore.user && gameStore.ready) saveToServer();
if (authStore.user && refs.ready) saveToServer();
});
window.addEventListener('beforeunload', () => {
if (!authStore.user || !gameStore.ready) return;
if (!authStore.user || !refs.ready) return;
const payload = JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.playSeconds,
gameState: state,
playTimeSeconds: refs.playSeconds,
});
fetch(`${BACKEND_URL}/api/save`, {
method: 'POST',

View File

@@ -1,5 +1,6 @@
// game.svelte.ts — Game store (Svelte 5 runes)
// Server = authority. localStorage = fallback guest only.
// Architecture: $state for all reactive values, direct access (no getter indirection).
import {
type GameState,
@@ -33,8 +34,6 @@ import {
const SAVE_KEY = 'clickerz_state';
const OFFLINE_THRESHOLD = 60_000;
// --- Offline report ---
export interface OfflineReport {
wasOffline: boolean;
duration: number;
@@ -42,31 +41,22 @@ export interface OfflineReport {
efficiency: number;
}
// --- Reactive state (Svelte 5 runes) ---
// $state.raw for GameState — replaced entirely on every mutation, never deep-patched.
// This ensures Svelte detects every change by reference equality.
// --- All reactive state ---
// Svelte 5 modules cannot export $state that is reassigned.
// Use a reactive container object + property mutation instead.
let _stateVersion = $state(0);
let _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);
export const refs = $state({
ready: false,
playSeconds: 0,
offlineReport: null as OfflineReport | null,
showPrestigeScreen: false,
lastClickGain: 0,
lastClickDouble: false,
lastClickCrit: false,
});
// Bump version to trigger reactivity on state reads
function setState(newState: GameState) {
_state = newState;
_stateVersion++;
}
function getState(): GameState {
// Read _stateVersion to create a reactive dependency
void _stateVersion;
return _state;
}
// Main game state: exported as $state object, mutated via Object.assign (never reassigned).
export const state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
// --- Local storage ---
@@ -99,29 +89,25 @@ function hydrateWithOffline(saved: GameState, now: number): { state: GameState;
const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = {
return {
state: {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
};
return {
state: hydrated,
},
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
};
}
// --- Actions ---
// --- Actions (exported directly, no wrapper object) ---
function tick() {
if (!ready) return;
export function tick() {
if (!refs.ready) return;
const now = Date.now();
const updated = applyIdleGains(_state, now);
updated.lastOnline = now;
const updated = { ...applyIdleGains(state, now), lastOnline: now };
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
@@ -129,8 +115,7 @@ function tick() {
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (playSeconds % 5 === 0) {
if (refs.playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
@@ -141,167 +126,144 @@ function tick() {
}
saveLocal(updated);
setState(updated);
playSeconds += 1;
Object.assign(state, updated);
refs.playSeconds += 1;
}
function click() {
if (!ready) return;
const result = applyClick(applyIdleGains(_state, Date.now()));
export function click() {
if (!refs.ready) return;
const result = applyClick(applyIdleGains(state, Date.now()));
saveLocal(result.state);
setState(result.state);
lastClickGain = result.gain;
lastClickDouble = result.isDouble;
lastClickCrit = result.isCrit;
Object.assign(state, result.state);
refs.lastClickGain = result.gain;
refs.lastClickDouble = result.isDouble;
refs.lastClickCrit = result.isCrit;
}
function buy(genId: string) {
if (!ready) return;
const withIdle = applyIdleGains(_state, Date.now());
const updated = buyGenerator(withIdle, genId);
export function buy(genId: string) {
if (!refs.ready) return;
const updated = buyGenerator(applyIdleGains(state, Date.now()), genId);
if (!updated) return;
saveLocal(updated);
setState(updated);
Object.assign(state, updated);
}
function buyNode(nodeId: string) {
if (!ready) return;
const updated = buyEvolutionNode(_state, nodeId);
export function buyNode(nodeId: string) {
if (!refs.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);
}
setState(updated);
if (node?.capstone) toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
Object.assign(state, updated);
}
function prestige() {
if (!ready) return;
if (!canPrestigeCheck(_state)) return;
const updated = applyPrestige(_state);
export function prestige() {
if (!refs.ready) return;
if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(state);
saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
setState(updated);
showPrestigeScreen = false;
Object.assign(state, updated);
refs.showPrestigeScreen = false;
}
function equipCosmetic(cosmeticId: string) {
if (!ready) return;
const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped };
export function equipCosmetic(cosmeticId: string) {
if (!refs.ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
setState(newState);
state.cosmeticEquipped = updated.equipped;
saveLocal(state);
}
function unequipCosmetic(slot: CosmeticSlot) {
if (!ready) return;
const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped };
export function unequipCosmetic(slot: CosmeticSlot) {
if (!refs.ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
setState(newState);
state.cosmeticEquipped = updated.equipped;
saveLocal(state);
}
function doResetTree() {
if (!ready) return;
if (!canResetTree(_state)) return;
const updated = resetEvolutionTree(_state);
export function doResetTree() {
if (!refs.ready) return;
if (!canResetTree(state)) return;
const updated = resetEvolutionTree(state);
saveLocal(updated);
setState(updated);
Object.assign(state, updated);
}
function doUpgradeConvergence() {
if (!ready) return;
const updated = upgradeConvergence(_state);
export function doUpgradeConvergence() {
if (!refs.ready) return;
const updated = upgradeConvergence(state);
if (!updated) return;
saveLocal(updated);
setState(updated);
Object.assign(state, updated);
}
function doClaimMilestone(milestoneId: string) {
if (!ready) return;
const updated = claimMilestoneFn(_state, milestoneId);
export function doClaimMilestone(milestoneId: string) {
if (!refs.ready) return;
const updated = claimMilestoneFn(state, milestoneId);
if (!updated) return;
saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000);
setState(updated);
Object.assign(state, updated);
}
function reset() {
export function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
setState(fresh);
playSeconds = 0;
ready = true;
offlineReport = null;
Object.assign(state, fresh);
refs.playSeconds = 0;
refs.ready = true;
refs.offlineReport = null;
}
function loadFromServer(serverState: GameState) {
export function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state);
setState(result.state);
ready = true;
offlineReport = result.report;
Object.assign(state, result.state);
refs.ready = true;
refs.offlineReport = result.report;
}
function initGuest() {
export function initGuest() {
const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state);
setState(result.state);
ready = true;
offlineReport = result.report;
Object.assign(state, result.state);
refs.ready = true;
refs.offlineReport = result.report;
}
function dismissOfflineReport() {
offlineReport = null;
export function dismissOfflineReport() {
refs.offlineReport = null;
}
function openPrestige() {
showPrestigeScreen = true;
export function openPrestige() {
refs.showPrestigeScreen = true;
}
function closePrestige() {
showPrestigeScreen = false;
export function closePrestige() {
refs.showPrestigeScreen = false;
}
// --- Public API ---
// All state reads go through getState() which depends on _stateVersion.
// This guarantees that any component reading gameStore.state re-renders
// whenever setState() is called — even for deep properties like evolutionTree[i].branch.
// --- Computed helpers (call from templates, they read `state` reactively) ---
export const gameStore = {
get state() { return getState(); },
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 canPrestigeCheck(getState()); },
get productionPerSecond() { return totalProductionPerSecond(getState()); },
export function getProductionPerSecond() {
return totalProductionPerSecond(state);
}
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, getState().evolutionTree),
getClickGain: () => getClickGain(getState()),
};
export function getCanPrestige() {
return canPrestigeCheck(state);
}
export function getCurrentClickGain() {
return getClickGain(state);
}
export function getGeneratorCostWithTree(gen: Parameters<typeof genCost>[0]) {
return genCost(gen, state.evolutionTree);
}
export { genCost as generatorCost };

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state } from '$lib/stores/game.svelte';
import { ACHIEVEMENTS } from '$lib/data/achievements';
let filter = $state<'all' | 'unlocked' | 'locked'>('all');
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(gameStore.state)));
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(gameStore.state)));
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(state)));
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(state)));
let displayed = $derived(
filter === 'unlocked' ? unlocked

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, elasticOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { state, refs, click } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import CockpitHeader from '$lib/components/CockpitHeader.svelte';
import GeneratorShop from '$lib/components/GeneratorShop.svelte';
@@ -16,7 +16,7 @@
import SidebarTabs from '$lib/components/SidebarTabs.svelte';
import { ACHIEVEMENTS } from '$lib/data/achievements';
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(gameStore.state)).length);
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(state)).length);
const sidebarTabs = [
{ id: 'production', label: 'Production', icon: '🏭' },
@@ -29,8 +29,8 @@
let tadpoleSprite: ReturnType<typeof TadpoleSprite>;
function handleClick(e: MouseEvent) {
gameStore.click();
clickParticles?.spawn(e.clientX, e.clientY, gameStore.lastClickGain, gameStore.lastClickDouble, gameStore.lastClickCrit);
click();
clickParticles?.spawn(e.clientX, e.clientY, refs.lastClickGain, refs.lastClickDouble, refs.lastClickCrit);
tadpoleSprite?.bounce();
}
@@ -43,7 +43,7 @@
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
</svelte:head>
{#if !gameStore.ready}
{#if !refs.ready}
<div class="flex items-center justify-center min-h-[80vh]" in:fade>
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 border-4 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
@@ -66,7 +66,7 @@
class="click-zone-counter"
in:fly={{ y: 20, duration: 400, easing: quintOut }}
>
{formatNumber(gameStore.state.resources)}
{formatNumber(state.resources)}
</div>
</div>