fix: store reactivity — version counter pattern for deep state changes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s

$state<GameState> caused Svelte 5 to lose track of deep property changes
(evolutionTree[i].branch, generators[i].owned, etc.).

Fix: _stateVersion counter + getState() creates explicit reactive deps.
canPrestige/productionPerSecond are now live getters, no manual updateDerived().
This commit is contained in:
2026-03-28 20:10:21 +01:00
parent f6bff6e389
commit ce38975c10

View File

@@ -43,8 +43,11 @@ export interface OfflineReport {
} }
// --- Reactive state (Svelte 5 runes) --- // --- 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.
let state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }); let _stateVersion = $state(0);
let _state: GameState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
let playSeconds = $state(0); let playSeconds = $state(0);
let ready = $state(false); let ready = $state(false);
let offlineReport = $state<OfflineReport | null>(null); let offlineReport = $state<OfflineReport | null>(null);
@@ -52,8 +55,18 @@ let showPrestigeScreen = $state(false);
let lastClickGain = $state(0); let lastClickGain = $state(0);
let lastClickDouble = $state(false); let lastClickDouble = $state(false);
let lastClickCrit = $state(false); let lastClickCrit = $state(false);
let canPrestige = $state(false);
let productionPerSecond = $state(0); // 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;
}
// --- Local storage --- // --- Local storage ---
@@ -100,19 +113,12 @@ function hydrateWithOffline(saved: GameState, now: number): { state: GameState;
}; };
} }
// --- Derived ---
function updateDerived() {
canPrestige = canPrestigeCheck(state);
productionPerSecond = totalProductionPerSecond(state);
}
// --- Actions --- // --- Actions ---
function tick() { function tick() {
if (!ready) return; if (!ready) return;
const now = Date.now(); const now = Date.now();
const updated = applyIdleGains(state, now); const updated = applyIdleGains(_state, now);
updated.lastOnline = now; updated.lastOnline = now;
// Auto-click from evolution tree // Auto-click from evolution tree
@@ -135,130 +141,119 @@ function tick() {
} }
saveLocal(updated); saveLocal(updated);
state = updated; setState(updated);
playSeconds += 1; playSeconds += 1;
updateDerived();
} }
function click() { function click() {
if (!ready) return; if (!ready) return;
const result = applyClick(applyIdleGains(state, Date.now())); const result = applyClick(applyIdleGains(_state, Date.now()));
saveLocal(result.state); saveLocal(result.state);
state = result.state; setState(result.state);
lastClickGain = result.gain; lastClickGain = result.gain;
lastClickDouble = result.isDouble; lastClickDouble = result.isDouble;
lastClickCrit = result.isCrit; lastClickCrit = result.isCrit;
updateDerived();
} }
function buy(genId: string) { function buy(genId: string) {
if (!ready) return; if (!ready) return;
const withIdle = applyIdleGains(state, Date.now()); const withIdle = applyIdleGains(_state, Date.now());
const updated = buyGenerator(withIdle, genId); const updated = buyGenerator(withIdle, genId);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
state = updated; setState(updated);
updateDerived();
} }
function buyNode(nodeId: string) { function buyNode(nodeId: string) {
if (!ready) return; if (!ready) return;
const updated = buyEvolutionNode(state, nodeId); const updated = buyEvolutionNode(_state, nodeId);
if (!updated) return; if (!updated) return;
const node = updated.evolutionTree.find((n) => n.id === nodeId); const node = updated.evolutionTree.find((n) => n.id === nodeId);
saveLocal(updated); saveLocal(updated);
if (node?.capstone) { if (node?.capstone) {
toast(`Capstone debloque : ${node.name} !`, 'reward', 5000); toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
} }
state = updated; setState(updated);
updateDerived();
} }
function prestige() { function prestige() {
if (!ready) return; if (!ready) return;
if (!canPrestigeCheck(state)) return; if (!canPrestigeCheck(_state)) return;
const updated = applyPrestige(state); const updated = applyPrestige(_state);
saveLocal(updated); saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000); toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
state = updated; setState(updated);
showPrestigeScreen = false; showPrestigeScreen = false;
updateDerived();
} }
function equipCosmetic(cosmeticId: string) { function equipCosmetic(cosmeticId: string) {
if (!ready) return; if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped }; const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId); const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ...state, cosmeticEquipped: updated.equipped }; const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState); saveLocal(newState);
state = newState; setState(newState);
} }
function unequipCosmetic(slot: CosmeticSlot) { function unequipCosmetic(slot: CosmeticSlot) {
if (!ready) return; if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped }; const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot); const updated = unequipSlotFn(cosState, slot);
const newState = { ...state, cosmeticEquipped: updated.equipped }; const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState); saveLocal(newState);
state = newState; setState(newState);
} }
function doResetTree() { function doResetTree() {
if (!ready) return; if (!ready) return;
if (!canResetTree(state)) return; if (!canResetTree(_state)) return;
const updated = resetEvolutionTree(state); const updated = resetEvolutionTree(_state);
saveLocal(updated); saveLocal(updated);
state = updated; setState(updated);
updateDerived();
} }
function doUpgradeConvergence() { function doUpgradeConvergence() {
if (!ready) return; if (!ready) return;
const updated = upgradeConvergence(state); const updated = upgradeConvergence(_state);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
state = updated; setState(updated);
updateDerived();
} }
function doClaimMilestone(milestoneId: string) { function doClaimMilestone(milestoneId: string) {
if (!ready) return; if (!ready) return;
const updated = claimMilestoneFn(state, milestoneId); const updated = claimMilestoneFn(_state, milestoneId);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000); toast('Milestone debloque !', 'reward', 4000);
state = updated; setState(updated);
} }
function reset() { function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh); saveLocal(fresh);
state = fresh; setState(fresh);
playSeconds = 0; playSeconds = 0;
ready = true; ready = true;
offlineReport = null; offlineReport = null;
canPrestige = false;
productionPerSecond = 0;
} }
function loadFromServer(serverState: GameState) { function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>); const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now()); const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state); saveLocal(result.state);
state = result.state; setState(result.state);
ready = true; ready = true;
offlineReport = result.report; offlineReport = result.report;
updateDerived();
} }
function initGuest() { function initGuest() {
const local = loadLocalState(); const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now()); const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state); saveLocal(result.state);
state = result.state; setState(result.state);
ready = true; ready = true;
offlineReport = result.report; offlineReport = result.report;
updateDerived();
} }
function dismissOfflineReport() { function dismissOfflineReport() {
@@ -273,10 +268,13 @@ function closePrestige() {
showPrestigeScreen = false; showPrestigeScreen = false;
} }
// --- Public API (single object export for ergonomic access) --- // --- 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.
export const gameStore = { export const gameStore = {
get state() { return state; }, get state() { return getState(); },
get playSeconds() { return playSeconds; }, get playSeconds() { return playSeconds; },
get ready() { return ready; }, get ready() { return ready; },
get offlineReport() { return offlineReport; }, get offlineReport() { return offlineReport; },
@@ -284,8 +282,8 @@ export const gameStore = {
get lastClickGain() { return lastClickGain; }, get lastClickGain() { return lastClickGain; },
get lastClickDouble() { return lastClickDouble; }, get lastClickDouble() { return lastClickDouble; },
get lastClickCrit() { return lastClickCrit; }, get lastClickCrit() { return lastClickCrit; },
get canPrestige() { return canPrestige; }, get canPrestige() { return canPrestigeCheck(getState()); },
get productionPerSecond() { return productionPerSecond; }, get productionPerSecond() { return totalProductionPerSecond(getState()); },
tick, tick,
click, click,
@@ -304,6 +302,6 @@ export const gameStore = {
openPrestige, openPrestige,
closePrestige, closePrestige,
generatorCost: genCost, generatorCost: genCost,
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, state.evolutionTree), generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, getState().evolutionTree),
getClickGain: () => getClickGain(state), getClickGain: () => getClickGain(getState()),
}; };