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:
|
elif action_type == 'hire_supplier' and drug_id in DRUG_CONFIGS:
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
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)
|
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:
|
if state['cash'] >= cost:
|
||||||
state['cash'] -= cost
|
state['cash'] -= cost
|
||||||
state['drugs'][drug_id]['suppliers'] = count + 1
|
state['drugs'][drug_id]['suppliers'] = count + 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS:
|
elif action_type == 'hire_worker' and drug_id in DRUG_CONFIGS:
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
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)
|
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:
|
if state['cash'] >= cost:
|
||||||
state['cash'] -= cost
|
state['cash'] -= cost
|
||||||
state['drugs'][drug_id]['workers'] = count + 1
|
state['drugs'][drug_id]['workers'] = count + 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS:
|
elif action_type == 'hire_dealer' and drug_id in DRUG_CONFIGS:
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
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)
|
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:
|
if state['cash'] >= cost:
|
||||||
state['cash'] -= cost
|
state['cash'] -= cost
|
||||||
state['drugs'][drug_id]['dealers'] = count + 1
|
state['drugs'][drug_id]['dealers'] = count + 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS:
|
elif action_type == 'unlock_drug' and drug_id in DRUG_CONFIGS:
|
||||||
cfg = DRUG_CONFIGS[drug_id]
|
cfg = DRUG_CONFIGS[drug_id]
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,14 @@ def sync_tick(req: SyncTickRequest, user_id: int = Depends(get_current_user_id))
|
||||||
else:
|
else:
|
||||||
state = engine.get_initial_state()
|
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:
|
if req.actions:
|
||||||
state = engine.apply_actions(state, 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
|
# 3. Persist authoritative clean state
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO saves (user_id, save_data, updated_at)
|
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)))
|
""", (user_id, json.dumps(state)))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return {"status": "ok", "save_data": state}
|
return {"status": "ok", "save_data": state, "ack_seq": req.seq}
|
||||||
|
|
||||||
@app.post("/api/game/save")
|
@app.post("/api/game/save")
|
||||||
def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_user_id)):
|
def save_game_state(req: SaveGameRequest, user_id: int = Depends(get_current_user_id)):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class AuthResponse(BaseModel):
|
||||||
|
|
||||||
class SyncTickRequest(BaseModel):
|
class SyncTickRequest(BaseModel):
|
||||||
actions: List[Dict[str, Any]] = []
|
actions: List[Dict[str, Any]] = []
|
||||||
|
seq: Optional[int] = None
|
||||||
|
|
||||||
class SaveGameRequest(BaseModel):
|
class SaveGameRequest(BaseModel):
|
||||||
save_data: Dict[str, Any]
|
save_data: Dict[str, Any]
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ html, body {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow-x: hidden;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* App Main Container: Clean, native-feeling layout on both mobile & desktop */
|
/* 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 {
|
#sticky-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 40;
|
z-index: 100;
|
||||||
background: rgba(11, 14, 18, 0.96);
|
background: rgba(11, 14, 18, 0.98);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border-bottom: 1px solid var(--border-color);
|
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 */
|
/* Status / Top Brand Bar */
|
||||||
|
|
@ -201,56 +202,7 @@ html, body {
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Server Sync Countdown Bar */
|
/* Quick Stash Overview Strip */
|
||||||
#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-strip {
|
#quick-stash-strip {
|
||||||
background: #080b0e;
|
background: #080b0e;
|
||||||
border-top: 1px solid rgba(36, 45, 54, 0.4);
|
border-top: 1px solid rgba(36, 45, 54, 0.4);
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Quick Inventory Overview Strip (Always visible) -->
|
||||||
<div id="quick-stash-strip">
|
<div id="quick-stash-strip">
|
||||||
<div class="stash-strip-title">
|
<div class="stash-strip-title">
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ const API = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async syncTick(actions = []) {
|
async syncTick(actions = [], seq = null) {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
|
|
@ -86,16 +86,16 @@ const API = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ actions })
|
body: JSON.stringify({ actions, seq })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) this.logout();
|
if (res.status === 401) this.logout();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.save_data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Network tick failed, queuing actions', e);
|
console.warn('Network sync failed, queuing actions', e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,11 @@ SYNTHESIS_RECIPES.forEach(r => {
|
||||||
|
|
||||||
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
|
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
|
||||||
let cashRateTracker = 0.0;
|
let cashRateTracker = 0.0;
|
||||||
let pendingActions = []; // Actions queued between 6-second authoritative server ticks
|
let pendingActions = []; // Actions queued waiting to be debounced & flushed
|
||||||
let isSyncing = false;
|
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) ---
|
// --- NUMBER FORMATTING HELPER (Max 1 decimal, graceful fallback) ---
|
||||||
function fmtNum(val) {
|
function fmtNum(val) {
|
||||||
|
|
@ -70,8 +73,6 @@ const dom = {
|
||||||
raidModal: document.getElementById('raid-modal'),
|
raidModal: document.getElementById('raid-modal'),
|
||||||
raidBribeCost: document.getElementById('raid-bribe-cost'),
|
raidBribeCost: document.getElementById('raid-bribe-cost'),
|
||||||
toastContainer: document.getElementById('toast-container'),
|
toastContainer: document.getElementById('toast-container'),
|
||||||
syncTimer: document.getElementById('sync-timer-text'),
|
|
||||||
syncBar: document.getElementById('sync-bar-fill'),
|
|
||||||
quickStash: document.getElementById('quick-stash-items')
|
quickStash: document.getElementById('quick-stash-items')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -486,9 +487,33 @@ function renderOffshoreView() {
|
||||||
dom.viewport.innerHTML = html;
|
dom.viewport.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- OPTIMISTIC CLIENT ACTIONS (Queued for Server Authority) ---
|
// --- OPTIMISTIC CLIENT ACTIONS (Debounced Aggregation & Server Reconciliation) ---
|
||||||
function queueAction(action) {
|
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) {
|
function buyRaw(drugId) {
|
||||||
|
|
@ -541,7 +566,7 @@ function hireSupplier(drugId) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
if (!G.drugs[drugId].suppliers) G.drugs[drugId].suppliers = 0;
|
if (!G.drugs[drugId].suppliers) G.drugs[drugId].suppliers = 0;
|
||||||
G.drugs[drugId].suppliers += 1;
|
G.drugs[drugId].suppliers += 1;
|
||||||
queueAction({ type: 'hire_supplier', drug_id: drugId });
|
queueAction({ type: 'hire_supplier', drug_id: drugId, count: 1 });
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
renderViewport();
|
renderViewport();
|
||||||
updateLiveInventory();
|
updateLiveInventory();
|
||||||
|
|
@ -553,7 +578,7 @@ function hireWorker(drugId) {
|
||||||
if (G.cash >= cost) {
|
if (G.cash >= cost) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].workers += 1;
|
G.drugs[drugId].workers += 1;
|
||||||
queueAction({ type: 'hire_worker', drug_id: drugId });
|
queueAction({ type: 'hire_worker', drug_id: drugId, count: 1 });
|
||||||
checkAchievements();
|
checkAchievements();
|
||||||
renderViewport();
|
renderViewport();
|
||||||
updateLiveInventory();
|
updateLiveInventory();
|
||||||
|
|
@ -565,7 +590,7 @@ function hireDealer(drugId) {
|
||||||
if (G.cash >= cost) {
|
if (G.cash >= cost) {
|
||||||
G.cash -= cost;
|
G.cash -= cost;
|
||||||
G.drugs[drugId].dealers += 1;
|
G.drugs[drugId].dealers += 1;
|
||||||
queueAction({ type: 'hire_dealer', drug_id: drugId });
|
queueAction({ type: 'hire_dealer', drug_id: drugId, count: 1 });
|
||||||
renderViewport();
|
renderViewport();
|
||||||
updateLiveInventory();
|
updateLiveInventory();
|
||||||
}
|
}
|
||||||
|
|
@ -773,24 +798,6 @@ function clientTick(deltaSec) {
|
||||||
|
|
||||||
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
|
// Direct, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
|
||||||
updateLiveInventory();
|
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() {
|
function updateLiveInventory() {
|
||||||
|
|
@ -887,34 +894,146 @@ function updateLiveInventory() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) ---
|
// --- AUTHORITATIVE SERVER SYNC & RECONCILIATION ---
|
||||||
async function authoritativeServerTick() {
|
function scheduleSync(delayMs = 300) {
|
||||||
if (isSyncing) return;
|
if (delayMs === 0) {
|
||||||
isSyncing = true;
|
if (syncDebounceTimer) {
|
||||||
lastSyncTimestamp = Date.now();
|
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];
|
const actionsToSend = [...pendingActions];
|
||||||
pendingActions = [];
|
pendingActions = [];
|
||||||
|
inFlightActions = [...actionsToSend];
|
||||||
|
|
||||||
|
actionSeq += 1;
|
||||||
|
const currentSeq = actionSeq;
|
||||||
|
isSyncing = true;
|
||||||
|
|
||||||
if (API.getToken()) {
|
if (API.getToken()) {
|
||||||
const serverState = await API.syncTick(actionsToSend);
|
const response = await API.syncTick(actionsToSend, currentSeq);
|
||||||
if (serverState) {
|
if (response && response.save_data) {
|
||||||
// Reconcile authoritative server state (protects against browser devtools tampering)
|
const serverState = response.save_data;
|
||||||
G = {
|
|
||||||
|
// Reconcile: Take authoritative server state as base
|
||||||
|
const reconciled = {
|
||||||
...INITIAL_GAME_STATE,
|
...INITIAL_GAME_STATE,
|
||||||
...serverState,
|
...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();
|
checkAchievements();
|
||||||
updateLiveInventory();
|
updateLiveInventory();
|
||||||
|
} else {
|
||||||
|
// Network failure / offline: merge inFlight back to pending
|
||||||
|
pendingActions = [...inFlightActions, ...pendingActions];
|
||||||
|
inFlightActions = [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Guest mode / offline fallback: persist locally
|
// Guest mode / offline fallback: persist locally
|
||||||
|
inFlightActions = [];
|
||||||
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
|
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
|
||||||
}
|
}
|
||||||
|
|
||||||
isSyncing = false;
|
isSyncing = false;
|
||||||
|
|
||||||
|
// If new actions arrived during sync, flush them
|
||||||
|
if (pendingActions.length > 0) {
|
||||||
|
scheduleSync(300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game loop with requestAnimationFrame
|
// Game loop with requestAnimationFrame
|
||||||
|
|
@ -928,8 +1047,12 @@ function gameLoop(now) {
|
||||||
requestAnimationFrame(gameLoop);
|
requestAnimationFrame(gameLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server tick: exactly every 6 seconds to prevent flooding server
|
// Background heartbeat (every 10 seconds): syncs idle accrual when player is not actively clicking
|
||||||
setInterval(authoritativeServerTick, TICK_INTERVAL_MS);
|
setInterval(() => {
|
||||||
|
if (!syncDebounceTimer && !isSyncing) {
|
||||||
|
triggerServerSync();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
// Clock updates
|
// Clock updates
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue