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) ---
// $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 ready = $state(false);
let offlineReport = $state<OfflineReport | null>(null);
@@ -52,8 +55,18 @@ let showPrestigeScreen = $state(false);
let lastClickGain = $state(0);
let lastClickDouble = $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 ---
@@ -100,19 +113,12 @@ function hydrateWithOffline(saved: GameState, now: number): { state: GameState;
};
}
// --- Derived ---
function updateDerived() {
canPrestige = canPrestigeCheck(state);
productionPerSecond = totalProductionPerSecond(state);
}
// --- Actions ---
function tick() {
if (!ready) return;
const now = Date.now();
const updated = applyIdleGains(state, now);
const updated = applyIdleGains(_state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
@@ -135,130 +141,119 @@ function tick() {
}
saveLocal(updated);
state = updated;
setState(updated);
playSeconds += 1;
updateDerived();
}
function click() {
if (!ready) return;
const result = applyClick(applyIdleGains(state, Date.now()));
const result = applyClick(applyIdleGains(_state, Date.now()));
saveLocal(result.state);
state = result.state;
setState(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 withIdle = applyIdleGains(_state, Date.now());
const updated = buyGenerator(withIdle, genId);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
setState(updated);
}
function buyNode(nodeId: string) {
if (!ready) return;
const updated = buyEvolutionNode(state, nodeId);
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();
setState(updated);
}
function prestige() {
if (!ready) return;
if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(state);
if (!canPrestigeCheck(_state)) return;
const updated = applyPrestige(_state);
saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
state = updated;
setState(updated);
showPrestigeScreen = false;
updateDerived();
}
function equipCosmetic(cosmeticId: string) {
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 newState = { ...state, cosmeticEquipped: updated.equipped };
const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
setState(newState);
}
function unequipCosmetic(slot: CosmeticSlot) {
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 newState = { ...state, cosmeticEquipped: updated.equipped };
const newState = { ..._state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
setState(newState);
}
function doResetTree() {
if (!ready) return;
if (!canResetTree(state)) return;
const updated = resetEvolutionTree(state);
if (!canResetTree(_state)) return;
const updated = resetEvolutionTree(_state);
saveLocal(updated);
state = updated;
updateDerived();
setState(updated);
}
function doUpgradeConvergence() {
if (!ready) return;
const updated = upgradeConvergence(state);
const updated = upgradeConvergence(_state);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
setState(updated);
}
function doClaimMilestone(milestoneId: string) {
if (!ready) return;
const updated = claimMilestoneFn(state, milestoneId);
const updated = claimMilestoneFn(_state, milestoneId);
if (!updated) return;
saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000);
state = updated;
setState(updated);
}
function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
state = fresh;
setState(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;
setState(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;
setState(result.state);
ready = true;
offlineReport = result.report;
updateDerived();
}
function dismissOfflineReport() {
@@ -273,10 +268,13 @@ function closePrestige() {
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 = {
get state() { return state; },
get state() { return getState(); },
get playSeconds() { return playSeconds; },
get ready() { return ready; },
get offlineReport() { return offlineReport; },
@@ -284,8 +282,8 @@ export const gameStore = {
get lastClickGain() { return lastClickGain; },
get lastClickDouble() { return lastClickDouble; },
get lastClickCrit() { return lastClickCrit; },
get canPrestige() { return canPrestige; },
get productionPerSecond() { return productionPerSecond; },
get canPrestige() { return canPrestigeCheck(getState()); },
get productionPerSecond() { return totalProductionPerSecond(getState()); },
tick,
click,
@@ -304,6 +302,6 @@ export const gameStore = {
openPrestige,
closePrestige,
generatorCost: genCost,
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, state.evolutionTree),
getClickGain: () => getClickGain(state),
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, getState().evolutionTree),
getClickGain: () => getClickGain(getState()),
};