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(() => {