feat(sync): implement debounced action aggregation, in-flight reconciliation and sticky header
This commit is contained in:
parent
10ddbb7049
commit
e34189ea44
7 changed files with 212 additions and 133 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue