From e34189ea441370b7f0112b5732ea662769346cdd Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 15 Oct 2026 17:31:59 +0200 Subject: [PATCH] feat(sync): implement debounced action aggregation, in-flight reconciliation and sticky header --- backend/engine.py | 45 ++++++--- backend/main.py | 11 ++- backend/models.py | 1 + frontend/css/style.css | 64 ++----------- frontend/index.html | 13 --- frontend/js/api.js | 8 +- frontend/js/game.js | 203 +++++++++++++++++++++++++++++++++-------- 7 files changed, 212 insertions(+), 133 deletions(-) diff --git a/backend/engine.py b/backend/engine.py index 1b07cbb..16848d8 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -187,27 +187,42 @@ def apply_actions(state: dict, actions: list) -> dict: elif action_type == 'hire_supplier' and drug_id in DRUG_CONFIGS: cfg = DRUG_CONFIGS[drug_id] - count = state['drugs'][drug_id].get('suppliers', 0) - cost = math.floor(cfg.get('supplierCost', 75) * math.pow(1.15, count)) - if state['cash'] >= cost: - state['cash'] -= cost - state['drugs'][drug_id]['suppliers'] = count + 1 + hire_qty = max(1, int(act.get('count', 1))) + base_cost = cfg.get('supplierCost', 75) + for _ in range(hire_qty): + count = state['drugs'][drug_id].get('suppliers', 0) + cost = math.floor(base_cost * math.pow(1.15, count)) + if state['cash'] >= cost: + state['cash'] -= cost + state['drugs'][drug_id]['suppliers'] = count + 1 + else: + break elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS: cfg = DRUG_CONFIGS[drug_id] - count = state['drugs'][drug_id].get('workers', 0) - cost = math.floor(cfg['workerCost'] * math.pow(1.15, count)) - if state['cash'] >= cost: - state['cash'] -= cost - state['drugs'][drug_id]['workers'] = count + 1 + hire_qty = max(1, int(act.get('count', 1))) + base_cost = cfg['workerCost'] + for _ in range(hire_qty): + count = state['drugs'][drug_id].get('workers', 0) + cost = math.floor(base_cost * math.pow(1.15, count)) + if state['cash'] >= cost: + state['cash'] -= cost + state['drugs'][drug_id]['workers'] = count + 1 + else: + break elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS: cfg = DRUG_CONFIGS[drug_id] - count = state['drugs'][drug_id].get('dealers', 0) - cost = math.floor(cfg['dealerCost'] * math.pow(1.15, count)) - if state['cash'] >= cost: - state['cash'] -= cost - state['drugs'][drug_id]['dealers'] = count + 1 + hire_qty = max(1, int(act.get('count', 1))) + base_cost = cfg['dealerCost'] + for _ in range(hire_qty): + count = state['drugs'][drug_id].get('dealers', 0) + cost = math.floor(base_cost * math.pow(1.15, count)) + if state['cash'] >= cost: + state['cash'] -= cost + state['drugs'][drug_id]['dealers'] = count + 1 + else: + break elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS: cfg = DRUG_CONFIGS[drug_id] diff --git a/backend/main.py b/backend/main.py index c1acc03..9767b1f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -120,13 +120,14 @@ def sync_tick(req: SyncTickRequest, user_id: int = Depends(get_current_user_id)) else: state = engine.get_initial_state() - # 1. Apply authorized player actions + # 1. Advance production, automation, and offline ticks up to right now + # Guarantees all idle income earned up to this exact second is credited before actions + state = engine.process_offline_and_ticks(state) + + # 2. Apply authorized player actions with fresh resource balances if req.actions: state = engine.apply_actions(state, req.actions) - # 2. Advance production, automation, and offline ticks - state = engine.process_offline_and_ticks(state) - # 3. Persist authoritative clean state cursor.execute(""" INSERT INTO saves (user_id, save_data, updated_at) @@ -137,7 +138,7 @@ def sync_tick(req: SyncTickRequest, user_id: int = Depends(get_current_user_id)) """, (user_id, json.dumps(state))) conn.commit() - return {"status": "ok", "save_data": state} + return {"status": "ok", "save_data": state, "ack_seq": req.seq} @app.post("/api/game/save") def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_user_id)): diff --git a/backend/models.py b/backend/models.py index 0900ec3..f4c7818 100644 --- a/backend/models.py +++ b/backend/models.py @@ -17,6 +17,7 @@ class AuthResponse(BaseModel): class SyncTickRequest(BaseModel): actions: List[Dict[str, Any]] = [] + seq: Optional[int] = None class SaveGameRequest(BaseModel): save_data: Dict[str, Any] diff --git a/frontend/css/style.css b/frontend/css/style.css index 3d40ce8..589a455 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -36,7 +36,7 @@ html, body { font-family: var(--font-ui); display: flex; justify-content: center; - overflow-x: hidden; + overflow-x: clip; } /* App Main Container: Clean, native-feeling layout on both mobile & desktop */ @@ -58,17 +58,18 @@ html, body { } } -/* Sticky Top Navigation & HUD Header */ +/* Sticky Top Navigation & HUD Header (Cash, Bonds, Heat, Stash) */ #sticky-header { position: sticky; top: 0; left: 0; right: 0; - z-index: 40; - background: rgba(11, 14, 18, 0.96); - backdrop-filter: blur(10px); + z-index: 100; + background: rgba(11, 14, 18, 0.98); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border-color); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7); } /* Status / Top Brand Bar */ @@ -201,56 +202,7 @@ html, body { transition: width 0.3s ease; } -/* Server Sync Countdown Bar */ -#sync-container { - background: #0b0e12; - padding: 3px 14px 6px; - display: flex; - flex-direction: column; - gap: 3px; - border-top: 1px solid rgba(36, 45, 54, 0.4); -} - -.sync-label-row { - display: flex; - justify-content: space-between; - font-family: var(--font-mono); - font-size: 0.62rem; - color: var(--text-secondary); -} - -.sync-status-text { - display: inline-flex; - align-items: center; - gap: 5px; - letter-spacing: 0.5px; -} - -.sync-pulse-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--neon-cyan); - box-shadow: 0 0 6px var(--neon-cyan); - animation: pulse 1.5s infinite; -} - -.sync-bar-track { - width: 100%; - height: 4px; - background: #151b22; - border-radius: 2px; - overflow: hidden; -} - -#sync-bar-fill { - width: 100%; - height: 100%; - background: linear-gradient(90deg, var(--neon-cyan) 0%, #3b82f6 100%); - transition: width 0.1s linear; -} - -/* Quick Stash Overview Strip (Sticky Under Sync Bar) */ +/* Quick Stash Overview Strip */ #quick-stash-strip { background: #080b0e; border-top: 1px solid rgba(36, 45, 54, 0.4); diff --git a/frontend/index.html b/frontend/index.html index 3a84979..8b4438c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -62,19 +62,6 @@ - -
-
- - NEXT SERVER TICK - - 6.0s -
-
-
-
-
-
diff --git a/frontend/js/api.js b/frontend/js/api.js index 5995c42..021c9fc 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -75,7 +75,7 @@ const API = { } }, - async syncTick(actions = []) { + async syncTick(actions = [], seq = null) { const token = this.getToken(); if (!token) return null; @@ -86,16 +86,16 @@ const API = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ actions }) + body: JSON.stringify({ actions, seq }) }); if (!res.ok) { if (res.status === 401) this.logout(); return null; } const data = await res.json(); - return data.save_data; + return data; } catch (e) { - console.warn('Network tick failed, queuing actions', e); + console.warn('Network sync failed, queuing actions', e); return null; } }, diff --git a/frontend/js/game.js b/frontend/js/game.js index 8829483..60f7576 100644 --- a/frontend/js/game.js +++ b/frontend/js/game.js @@ -39,8 +39,11 @@ SYNTHESIS_RECIPES.forEach(r => { let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE)); let cashRateTracker = 0.0; -let pendingActions = []; // Actions queued between 6-second authoritative server ticks -let isSyncing = false; +let pendingActions = []; // Actions queued waiting to be debounced & flushed +let inFlightActions = []; // Actions currently travelling in an active HTTP request +let actionSeq = 0; // Monotonic sequence ID +let syncDebounceTimer = null; // 300ms debounce timer for rapid clicking +let isSyncing = false; // Network in-flight flag // --- NUMBER FORMATTING HELPER (Max 1 decimal, graceful fallback) --- function fmtNum(val) { @@ -70,8 +73,6 @@ const dom = { raidModal: document.getElementById('raid-modal'), raidBribeCost: document.getElementById('raid-bribe-cost'), toastContainer: document.getElementById('toast-container'), - syncTimer: document.getElementById('sync-timer-text'), - syncBar: document.getElementById('sync-bar-fill'), quickStash: document.getElementById('quick-stash-items') }; @@ -486,9 +487,33 @@ function renderOffshoreView() { dom.viewport.innerHTML = html; } -// --- OPTIMISTIC CLIENT ACTIONS (Queued for Server Authority) --- +// --- OPTIMISTIC CLIENT ACTIONS (Debounced Aggregation & Server Reconciliation) --- function queueAction(action) { - pendingActions.push(action); + // Action aggregation: consolidate rapid repetitive clicks + if (['buy_raw', 'manufacture', 'sell_manual'].includes(action.type)) { + const existing = pendingActions.find(a => a.type === action.type && a.drug_id === action.drug_id); + if (existing) { + existing.amount = (existing.amount || 1) + (action.amount || 1); + scheduleSync(300); + return; + } + } else if (['hire_supplier', 'hire_worker', 'hire_dealer'].includes(action.type)) { + const existing = pendingActions.find(a => a.type === action.type && a.drug_id === action.drug_id); + if (existing) { + existing.count = (existing.count || 1) + (action.count || 1); + scheduleSync(300); + return; + } + } + + pendingActions.push({ ...action }); + + // Major discrete events flush immediately, micro-actions debounce 300ms + if (['launder_prestige', 'bribe_police', 'flush_stash', 'unlock_drug', 'buy_skill', 'blend_recipe'].includes(action.type)) { + scheduleSync(0); + } else { + scheduleSync(300); + } } function buyRaw(drugId) { @@ -541,7 +566,7 @@ function hireSupplier(drugId) { G.cash -= cost; if (!G.drugs[drugId].suppliers) G.drugs[drugId].suppliers = 0; G.drugs[drugId].suppliers += 1; - queueAction({ type: 'hire_supplier', drug_id: drugId }); + queueAction({ type: 'hire_supplier', drug_id: drugId, count: 1 }); checkAchievements(); renderViewport(); updateLiveInventory(); @@ -553,7 +578,7 @@ function hireWorker(drugId) { if (G.cash >= cost) { G.cash -= cost; G.drugs[drugId].workers += 1; - queueAction({ type: 'hire_worker', drug_id: drugId }); + queueAction({ type: 'hire_worker', drug_id: drugId, count: 1 }); checkAchievements(); renderViewport(); updateLiveInventory(); @@ -565,7 +590,7 @@ function hireDealer(drugId) { if (G.cash >= cost) { G.cash -= cost; G.drugs[drugId].dealers += 1; - queueAction({ type: 'hire_dealer', drug_id: drugId }); + queueAction({ type: 'hire_dealer', drug_id: drugId, count: 1 }); renderViewport(); updateLiveInventory(); } @@ -773,24 +798,6 @@ function clientTick(deltaSec) { // Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML) updateLiveInventory(); - updateSyncCountdown(); -} - -const TICK_INTERVAL_MS = 6000; -let lastSyncTimestamp = Date.now(); - -function updateSyncCountdown() { - const elapsed = Date.now() - lastSyncTimestamp; - const remainingMs = Math.max(0, TICK_INTERVAL_MS - elapsed); - const remainingSec = (remainingMs / 1000).toFixed(1); - const pct = Math.min(100, Math.max(0, (remainingMs / TICK_INTERVAL_MS) * 100)); - - if (dom.syncTimer) { - dom.syncTimer.textContent = `${remainingSec}s`; - } - if (dom.syncBar) { - dom.syncBar.style.width = `${pct}%`; - } } function updateLiveInventory() { @@ -887,34 +894,146 @@ function updateLiveInventory() { } } -// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) --- -async function authoritativeServerTick() { - if (isSyncing) return; - isSyncing = true; - lastSyncTimestamp = Date.now(); +// --- AUTHORITATIVE SERVER SYNC & RECONCILIATION --- +function scheduleSync(delayMs = 300) { + if (delayMs === 0) { + if (syncDebounceTimer) { + clearTimeout(syncDebounceTimer); + syncDebounceTimer = null; + } + triggerServerSync(); + return; + } + if (syncDebounceTimer) clearTimeout(syncDebounceTimer); + syncDebounceTimer = setTimeout(() => { + syncDebounceTimer = null; + triggerServerSync(); + }, delayMs); +} + +function applyLocalActions(state, actions) { + const rawCostMulti = getRawCostMultiplier(); + const sellPriceMulti = getSellPriceMultiplier(); + + actions.forEach(act => { + const dId = act.drug_id; + const dCfg = DRUG_CATALOG.find(x => x.id === dId); + if (!state.drugs || !state.drugs[dId]) return; + + if (act.type === 'buy_raw' && dCfg) { + const amount = act.amount || 1; + const unitCost = Math.floor(dCfg.rawCost * rawCostMulti); + const total = unitCost * amount; + if (state.cash >= total) { + state.cash -= total; + state.drugs[dId].raw += amount; + } + } else if (act.type === 'manufacture') { + const amount = act.amount || 1; + const actual = Math.min(amount, Math.floor(state.drugs[dId].raw)); + if (actual > 0) { + state.drugs[dId].raw -= actual; + state.drugs[dId].product += actual; + } + } else if (act.type === 'sell_manual' && dCfg) { + const amount = act.amount || 1; + const actual = Math.min(amount, Math.floor(state.drugs[dId].product)); + if (actual > 0) { + state.drugs[dId].product -= actual; + const profit = actual * Math.floor(dCfg.sellPrice * sellPriceMulti); + state.cash += profit; + state.totalEarned += profit; + } + } else if (act.type === 'hire_supplier' && dCfg) { + const count = act.count || 1; + for (let i = 0; i < count; i++) { + const currentCount = state.drugs[dId].suppliers || 0; + const cost = Math.floor((dCfg.supplierCost || 75) * Math.pow(1.15, currentCount)); + if (state.cash >= cost) { + state.cash -= cost; + state.drugs[dId].suppliers = currentCount + 1; + } + } + } else if (act.type === 'hire_worker' && dCfg) { + const count = act.count || 1; + for (let i = 0; i < count; i++) { + const currentCount = state.drugs[dId].workers || 0; + const cost = Math.floor(dCfg.workerCost * Math.pow(1.15, currentCount)); + if (state.cash >= cost) { + state.cash -= cost; + state.drugs[dId].workers = currentCount + 1; + } + } + } else if (act.type === 'hire_dealer' && dCfg) { + const count = act.count || 1; + for (let i = 0; i < count; i++) { + const currentCount = state.drugs[dId].dealers || 0; + const cost = Math.floor(dCfg.dealerCost * Math.pow(1.15, currentCount)); + if (state.cash >= cost) { + state.cash -= cost; + state.drugs[dId].dealers = currentCount + 1; + } + } + } + }); +} + +async function triggerServerSync() { + if (isSyncing) { + // Postpone flush while another network request is in flight + scheduleSync(200); + return; + } - // Grab queued actions and reset local queue const actionsToSend = [...pendingActions]; pendingActions = []; + inFlightActions = [...actionsToSend]; + + actionSeq += 1; + const currentSeq = actionSeq; + isSyncing = true; if (API.getToken()) { - const serverState = await API.syncTick(actionsToSend); - if (serverState) { - // Reconcile authoritative server state (protects against browser devtools tampering) - G = { + const response = await API.syncTick(actionsToSend, currentSeq); + if (response && response.save_data) { + const serverState = response.save_data; + + // Reconcile: Take authoritative server state as base + const reconciled = { ...INITIAL_GAME_STATE, ...serverState, - activeTab: G.activeTab // Preserve UI view tab + activeTab: G.activeTab }; + + // In-flight actions have been committed to the server! + inFlightActions = []; + + // Apply any new actions that user clicked while the request was in flight! + // This guarantees zero dropped actions or rollbacks. + if (pendingActions.length > 0) { + applyLocalActions(reconciled, pendingActions); + } + + G = reconciled; checkAchievements(); updateLiveInventory(); + } else { + // Network failure / offline: merge inFlight back to pending + pendingActions = [...inFlightActions, ...pendingActions]; + inFlightActions = []; } } else { // Guest mode / offline fallback: persist locally + inFlightActions = []; localStorage.setItem('el_puro_local_save', JSON.stringify(G)); } isSyncing = false; + + // If new actions arrived during sync, flush them + if (pendingActions.length > 0) { + scheduleSync(300); + } } // Game loop with requestAnimationFrame @@ -928,8 +1047,12 @@ function gameLoop(now) { requestAnimationFrame(gameLoop); } -// Server tick: exactly every 6 seconds to prevent flooding server -setInterval(authoritativeServerTick, TICK_INTERVAL_MS); +// Background heartbeat (every 10 seconds): syncs idle accrual when player is not actively clicking +setInterval(() => { + if (!syncDebounceTimer && !isSyncing) { + triggerServerSync(); + } +}, 10000); // Clock updates setInterval(() => {