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) ---
|
// --- 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()),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user