fix: store reactivity — version counter pattern for deep state changes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
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:
@@ -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()),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user