feat(sync): implement debounced action aggregation, in-flight reconciliation and sticky header

This commit is contained in:
Christian 2026-10-15 17:31:59 +02:00
parent 10ddbb7049
commit e34189ea44
7 changed files with 212 additions and 133 deletions

View file

@ -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]
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(cfg.get('supplierCost', 75) * math.pow(1.15, count))
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]
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(cfg['workerCost'] * math.pow(1.15, count))
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]
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(cfg['dealerCost'] * math.pow(1.15, count))
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]

View file

@ -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)):

View file

@ -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]

View file

@ -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);

View file

@ -62,19 +62,6 @@
</div>
</div>
<!-- Server Sync Countdown Bar -->
<div id="sync-container">
<div class="sync-label-row">
<span class="sync-status-text">
<span class="sync-pulse-dot"></span> NEXT SERVER TICK
</span>
<span id="sync-timer-text">6.0s</span>
</div>
<div class="sync-bar-track">
<div id="sync-bar-fill"></div>
</div>
</div>
<!-- Quick Inventory Overview Strip (Always visible) -->
<div id="quick-stash-strip">
<div class="stash-strip-title">

View file

@ -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;
}
},

View file

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