// EL PURO - SilkNet OS Core Engine (Authoritative Server Synchronization)
const INITIAL_GAME_STATE = {
cash: 25.0,
cleanBonds: 0,
totalCleanBonds: 0,
heat: 0.0,
totalEarned: 0.0,
activeTab: 'lab',
buyMode: '1',
stats: {
totalProduced: 0.0,
totalSold: 0.0,
totalSupplied: 0.0,
purityProcs: 0,
raidsSurvived: 0,
prestigeCount: 0
},
drugs: {},
skills: {},
achievements: {}, // { [achId]: claimed_tier_int }
offshorePerks: {}, // { [perkId]: level_int }
synthesis: {},
lastTick: Date.now()
};
// Initialize drugs state
DRUG_CATALOG.forEach(d => {
INITIAL_GAME_STATE.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0.0,
product: 0.0,
suppliers: 0,
workers: 0,
dealers: 0
};
});
SKILL_TREE.forEach(s => {
INITIAL_GAME_STATE.skills[s.id] = false;
});
ACHIEVEMENT_TIERS.forEach(a => {
INITIAL_GAME_STATE.achievements[a.id] = 0;
});
OFFSHORE_PERKS.forEach(p => {
INITIAL_GAME_STATE.offshorePerks[p.id] = 0;
});
SYNTHESIS_RECIPES.forEach(r => {
INITIAL_GAME_STATE.synthesis[r.id] = 0;
});
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
let cashRateTracker = 0.0;
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
// --- USER PREFERENCES (Number Notation: 'standard' vs 'scientific') ---
let userNotation = localStorage.getItem('el_puro_notation') || 'standard';
// Large Number Suffixes for "Idle Game Progression" Notation
const NUMBER_SUFFIXES = [
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No',
'Dc', 'Ud', 'Dd', 'Td', 'Qad', 'Qid', 'Sxd', 'Spd', 'Ocd', 'Nod',
'Vg', 'Uvg', 'Dvg', 'Tvg', 'Qavg', 'Qivg', 'Sxvg', 'Spvg', 'Ocvg', 'Novg',
'Tg'
];
/**
* Formats a number with strictly 2 decimal places.
* Supports:
* 1. 'standard': Idle game notation (K, M, B, T, Qa, Qi...) for >= 1,000,000 or commas for < 1,000,000.
* 2. 'scientific': e.g. 1.25e6, 3.40e12 for >= 1,000,000.
* Numbers < 1,000,000 are always formatted with commas and exactly 2 decimal places.
*/
function fmtNum(val) {
if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0.00';
const num = Number(val);
if (num === 0) return '0.00';
const abs = Math.abs(num);
const sign = num < 0 ? '-' : '';
if (abs < 1e6) {
return sign + abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
if (userNotation === 'scientific') {
const exponent = Math.floor(Math.log10(abs));
const mantissa = abs / Math.pow(10, exponent);
return sign + mantissa.toFixed(2) + 'e' + exponent;
}
// Standard Idle Game Progression Notation
const tier = Math.floor(Math.log10(abs) / 3);
if (tier < NUMBER_SUFFIXES.length) {
const scaled = abs / Math.pow(10, tier * 3);
return sign + scaled.toFixed(2) + NUMBER_SUFFIXES[tier];
} else {
// Fallback to scientific for numbers beyond defined suffixes (> 10^96)
const exponent = Math.floor(Math.log10(abs));
const mantissa = abs / Math.pow(10, exponent);
return sign + mantissa.toFixed(2) + 'e' + exponent;
}
}
/**
* Formats an integer or unit count.
* If >= 1,000,000, formats with 2 decimal places + suffix or exponent.
* If < 1,000,000, formats with integer commas.
*/
function fmtInt(val) {
if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0';
const num = Math.floor(Math.max(0, Number(val)));
if (num < 1e6) {
return num.toLocaleString();
}
return fmtNum(num);
}
// --- CUSTOM MODAL SYSTEM (Replaces window.alert and window.confirm) ---
let activeModalResolve = null;
function showConfirmModal(title, message, options = {}) {
return new Promise(resolve => {
activeModalResolve = resolve;
const modal = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-modal-title');
const msgEl = document.getElementById('confirm-modal-message');
const iconEl = document.getElementById('confirm-modal-icon');
const cancelBtn = document.getElementById('btn-confirm-cancel');
const okBtn = document.getElementById('btn-confirm-ok');
if (titleEl) titleEl.textContent = title || 'CONFIRMATION';
if (msgEl) msgEl.innerHTML = message || 'Proceed with this operation?';
if (options.isAlert) {
if (cancelBtn) cancelBtn.style.display = 'none';
if (okBtn) okBtn.textContent = options.okText || 'Understood';
if (iconEl) iconEl.setAttribute('icon', 'lucide:info');
} else {
if (cancelBtn) {
cancelBtn.style.display = 'block';
cancelBtn.textContent = options.cancelText || 'Cancel';
}
if (okBtn) okBtn.textContent = options.okText || 'Confirm';
if (iconEl) iconEl.setAttribute('icon', options.icon || 'lucide:alert-circle');
}
modal.classList.add('active');
});
}
function showAlertModal(title, message, options = {}) {
return showConfirmModal(title, message, { ...options, isAlert: true });
}
function closeModal(result) {
const modal = document.getElementById('confirm-modal');
if (modal) modal.classList.remove('active');
if (activeModalResolve) {
activeModalResolve(result);
activeModalResolve = null;
}
}
// --- DOM CACHE ---
const dom = {
cash: document.getElementById('hud-cash'),
rate: document.getElementById('hud-rate'),
bonds: document.getElementById('hud-bonds'),
multiplier: document.getElementById('hud-multiplier'),
heatPct: document.getElementById('heat-pct'),
heatBar: document.getElementById('heat-bar-fill'),
viewport: document.getElementById('main-viewport'),
clock: document.getElementById('system-clock'),
userBadge: document.getElementById('user-badge'),
authModal: document.getElementById('auth-modal'),
raidModal: document.getElementById('raid-modal'),
raidBribeCost: document.getElementById('raid-bribe-cost'),
toastContainer: document.getElementById('toast-container'),
quickStash: document.getElementById('quick-stash-items'),
trophyBadge: document.getElementById('trophy-badge'),
settingsModal: document.getElementById('settings-modal'),
btnOpenSettings: document.getElementById('btn-open-settings')
};
// --- MILESTONES & BULK FORMULAS ---
function getUnitMilestoneMultiplier(count) {
const reached = UNIT_MILESTONES.filter(m => count >= m).length;
return Math.pow(2, reached);
}
function getNextMilestone(count) {
const next = UNIT_MILESTONES.find(m => m > count);
return next || (Math.floor(count / 1000) + 1) * 1000;
}
function getAllTierMilestoneMultiplier(unitKey) {
const unlocked = DRUG_CATALOG.filter(d => G.drugs[d.id] && G.drugs[d.id].unlocked);
if (!unlocked.length) return 1.0;
const minCount = Math.min(...unlocked.map(d => (G.drugs[d.id] && G.drugs[d.id][unitKey]) || 0));
const reached = UNIT_MILESTONES.filter(m => minCount >= m).length;
return Math.pow(2, reached);
}
function getPriceScaling() {
const discountLvl = (G.offshorePerks && G.offshorePerks['bulk_discount']) || 0;
return Math.max(1.10, 1.15 - (discountLvl * 0.01));
}
// Geometric Series Cost: sum of baseCost * r^(count + i) for i = 0 to n-1
function getGeometricBulkCost(baseCost, currentCount, n) {
if (n <= 0) return 0;
const r = getPriceScaling();
const firstTerm = baseCost * Math.pow(r, currentCount);
if (n === 1) return Math.floor(firstTerm);
const total = firstTerm * (Math.pow(r, n) - 1.0) / (r - 1.0);
return Math.floor(total);
}
// Maximum units affordable with available cash
function getMaxAffordableUnits(baseCost, currentCount, availableCash) {
if (availableCash <= 0) return 0;
const r = getPriceScaling();
const firstTerm = baseCost * Math.pow(r, currentCount);
if (availableCash < firstTerm) return 0;
const numerator = availableCash * (r - 1.0);
const n = Math.floor(Math.log(1.0 + (numerator / firstTerm)) / Math.log(r));
return Math.max(0, n);
}
// Helper to determine exact unit count & total price based on active G.buyMode
function getUnitPurchaseInfo(drugId, unitType) {
const config = DRUG_CATALOG.find(d => d.id === drugId);
const data = G.drugs[drugId] || { suppliers: 0, workers: 0, dealers: 0 };
const currentCount = data[unitType] || 0;
const baseCost = unitType === 'suppliers' ? (config.supplierCost || 75)
: unitType === 'workers' ? config.workerCost
: config.dealerCost;
let qty = 1;
if (G.buyMode === '10') {
qty = 10;
} else if (G.buyMode === '100') {
qty = 100;
} else if (G.buyMode === 'next') {
const nextM = getNextMilestone(currentCount);
qty = Math.max(1, nextM - currentCount);
} else if (G.buyMode === 'max') {
const maxQty = getMaxAffordableUnits(baseCost, currentCount, G.cash);
qty = Math.max(1, maxQty);
}
const totalCost = getGeometricBulkCost(baseCost, currentCount, qty);
return { qty, totalCost, baseCost, currentCount };
}
function getRawPurchaseInfo(drugId) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const unitCost = Math.floor(d.rawCost * getRawCostMultiplier());
let qty = 1;
if (G.buyMode === '10') qty = 10;
else if (G.buyMode === '100') qty = 100;
else if (G.buyMode === 'next') qty = 25;
else if (G.buyMode === 'max') qty = Math.max(1, Math.floor(G.cash / unitCost));
const totalCost = unitCost * qty;
return { qty, totalCost };
}
// --- MULTIPLIERS & BONUSES ---
function getProductionMultiplier() {
let m = 1.0;
const totalBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0);
m += totalBonds * 0.03;
if (G.skills['chem_1']) m += 0.15;
// Tiered achievements
const synthTier = (G.achievements && G.achievements['master_synthesizer']) || 0;
m += [0, 0.05, 0.10, 0.15, 0.25, 0.50][Math.min(5, synthTier)];
const formTier = (G.achievements && G.achievements['formula_expansion']) || 0;
m += [0, 0.05, 0.10, 0.15, 0.30][Math.min(4, formTier)];
return m;
}
function getSellPriceMultiplier() {
let m = 1.0;
const totalBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0);
const compoundLvl = (G.offshorePerks && G.offshorePerks['compound_interest']) || 0;
const bondRate = 0.04 + (compoundLvl * 0.02);
m += totalBonds * bondRate;
if (G.skills['king_2']) m += 0.25;
const empireTier = (G.achievements && G.achievements['dirty_empire']) || 0;
m += [0, 0.05, 0.10, 0.15, 0.25, 0.50][Math.min(5, empireTier)];
return m;
}
function getRawCostMultiplier() {
let m = 1.0;
if (G.skills['chem_2']) m *= 0.85;
const vaultTier = (G.achievements && G.achievements['vault_hoarder']) || 0;
const discount = [0, 0.05, 0.10, 0.15, 0.25][Math.min(4, vaultTier)];
return Math.max(0.1, m - discount);
}
function getDealerSpeedMultiplier() {
let m = 1.0;
if (G.skills['king_1']) m += 0.20;
if (G.skills['king_3']) m += 0.50;
const streetTier = (G.achievements && G.achievements['street_monopoly']) || 0;
m += [0, 0.05, 0.10, 0.15, 0.25, 0.50][Math.min(5, streetTier)];
const armyTier = (G.achievements && G.achievements['syndicate_army']) || 0;
m *= (1.0 + [0, 0.05, 0.10, 0.15, 0.25][Math.min(4, armyTier)]);
return m;
}
function getSupplierSpeedMultiplier() {
let m = 1.0;
const supplyTier = (G.achievements && G.achievements['supply_baron']) || 0;
m += [0, 0.05, 0.10, 0.15, 0.25][Math.min(4, supplyTier)];
const armyTier = (G.achievements && G.achievements['syndicate_army']) || 0;
m *= (1.0 + [0, 0.05, 0.10, 0.15, 0.25][Math.min(4, armyTier)]);
return m;
}
function getWorkerSpeedMultiplier() {
let m = 1.0;
const armyTier = (G.achievements && G.achievements['syndicate_army']) || 0;
m *= (1.0 + [0, 0.05, 0.10, 0.15, 0.25][Math.min(4, armyTier)]);
return m;
}
function getUnlockCost(drugId) {
return DRUG_CATALOG.find(d => d.id === drugId).unlockCost;
}
// --- NOTIFICATIONS (TOASTS) ---
function showToast(msg, isDanger = false) {
const toast = document.createElement('div');
toast.className = `toast ${isDanger ? 'danger' : ''}`;
toast.textContent = msg;
dom.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 2800);
}
// --- TAB SWITCHING ---
function setTab(tabId) {
G.activeTab = tabId;
document.querySelectorAll('.nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tabId);
});
renderViewport();
}
// --- RENDER VIEWPORT ---
function renderViewport() {
if (G.activeTab === 'lab') {
renderLabView();
} else if (G.activeTab === 'streets') {
renderStreetsView();
} else if (G.activeTab === 'trophies') {
renderTrophiesView();
} else if (G.activeTab === 'tree') {
renderSkillTreeView();
} else if (G.activeTab === 'offshore') {
renderOffshoreView();
}
}
// 1. LAB VIEW
function renderLabView() {
let html = `
ACTIVE LABORATORIES
SYNTHESIS LEVEL: ${G.cleanBonds}
`;
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id] || { raw: 0, product: 0, suppliers: 0, workers: 0, dealers: 0, unlocked: false };
if (!data.unlocked) {
const unlockCost = getUnlockCost(d.id);
html += `
Acquire Formula ($${unlockCost.toLocaleString()})
`;
return;
}
const suppliers = data.suppliers || 0;
const workers = data.workers || 0;
const supplyRate = d.supplyRate || 1.0;
const supMilestoneM = getUnitMilestoneMultiplier(suppliers);
const workMilestoneM = getUnitMilestoneMultiplier(workers);
const allTierSupM = getAllTierMilestoneMultiplier('suppliers');
const allTierWorkM = getAllTierMilestoneMultiplier('workers');
const autoSupplySpeed = suppliers * supplyRate * getProductionMultiplier() * getSupplierSpeedMultiplier() * supMilestoneM * allTierSupM;
const autoProdSpeed = workers * d.produceRate * getProductionMultiplier() * getWorkerSpeedMultiplier() * workMilestoneM * allTierWorkM;
const nextSupM = getNextMilestone(suppliers);
const nextWorkM = getNextMilestone(workers);
const rawInfo = getRawPurchaseInfo(d.id);
const supInfo = getUnitPurchaseInfo(d.id, 'suppliers');
const workInfo = getUnitPurchaseInfo(d.id, 'workers');
html += `
RAW SUPPLY (${d.rawName.toUpperCase()})
${fmtNum(data.raw)}
READY PRODUCT (${d.productName.toUpperCase()})
${fmtNum(data.product)}g
Buy Raw (+${rawInfo.qty})
$${rawInfo.totalCost.toLocaleString()}
Synthesize
1 Raw -> 1g
Quick Deal
+$${Math.floor(d.sellPrice * getSellPriceMultiplier()).toLocaleString()}
Hire ${d.supplierName} (+${supInfo.qty})
$${supInfo.totalCost.toLocaleString()}
Hire ${d.workerName} (+${workInfo.qty})
$${workInfo.totalCost.toLocaleString()}
`;
});
dom.viewport.innerHTML = html;
}
// 2. STREETS VIEW (Dealers & Blends)
function renderStreetsView() {
let html = `
STREET DISTRIBUTION & DEALER NETWORK
`;
DRUG_CATALOG.filter(d => G.drugs[d.id] && G.drugs[d.id].unlocked).forEach(d => {
const data = G.drugs[d.id];
const dealers = data.dealers || 0;
const dealMilestoneM = getUnitMilestoneMultiplier(dealers);
const allTierDealM = getAllTierMilestoneMultiplier('dealers');
const dealInfo = getUnitPurchaseInfo(d.id, 'dealers');
const nextDealM = getNextMilestone(dealers);
const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier());
const dealSpeed = dealers * d.sellRate * getDealerSpeedMultiplier() * dealMilestoneM * allTierDealM;
html += `
In Stash
${fmtNum(data.product)}g
Deal Speed
${fmtNum(dealSpeed)}/s
Hire ${d.dealerName} (+${dealInfo.qty})
$${dealInfo.totalCost.toLocaleString()}
Sell 10x Stash
+$${(unitPrice * Math.min(10, Math.floor(data.product))).toLocaleString()}
`;
});
// RECIPE SYNTHESIS SECTION
html += `
DESIGNER COCKTAILS & BLENDS
`;
SYNTHESIS_RECIPES.forEach(r => {
const canCook = Object.entries(r.ingredients).every(([drugId, reqAmount]) => {
return G.drugs[drugId] && G.drugs[drugId].product >= reqAmount;
});
const ingrLabels = Object.entries(r.ingredients).map(([id, amt]) => {
const has = G.drugs[id] ? G.drugs[id].product : 0;
const drugDef = DRUG_CATALOG.find(d => d.id === id);
return `${amt}g ${drugDef.name} (${fmtNum(has)}g) `;
}).join(' + ');
html += `
Recipe: ${ingrLabels}
Cook & Distribute Blend
Instant Payout: +$${r.sellPrice.toLocaleString()}
`;
});
dom.viewport.innerHTML = html;
}
// 3. SKILL TREE (SPIDERWEB)
function renderSkillTreeView() {
let html = `
UNDERWORLD TALENT SPIDERWEB
`;
const branches = [
{ id: 'chemist', name: 'Path of the Chemist', icon: 'game-icons:chemical-drop', color: '#00e5ff' },
{ id: 'kingpin', name: 'Path of the Kingpin', icon: 'game-icons:crown', color: '#eab308' },
{ id: 'smuggler', name: 'Path of the Smuggler', icon: 'game-icons:stealth-bomber', color: '#a855f7' }
];
branches.forEach(b => {
html += `
${b.name.toUpperCase()}
`;
const skillsInBranch = SKILL_TREE.filter(s => s.branch === b.id);
skillsInBranch.forEach(s => {
const isUnlocked = G.skills[s.id];
const prereqsMet = s.prereqs.every(req => G.skills[req]);
html += `
${s.name} ${isUnlocked ? '✓' : ''}
${s.desc}
${isUnlocked ? 'ACQUIRED ' : '$' + s.cost.toLocaleString()}
${!isUnlocked ? `
${!prereqsMet ? 'Requires Prior Link' : 'Unlock Node ($' + s.cost.toLocaleString() + ')'}
` : ''}
`;
});
});
dom.viewport.innerHTML = html;
}
// 3. TROPHIES VIEW (Multi-Tiered Achievements)
function renderTrophiesView() {
let html = `
SYNDICATE TROPHIES & CAREER MILESTONES
CAREER VOL: $${fmtNum(G.totalEarned)}
`;
ACHIEVEMENT_TIERS.forEach(ach => {
const currentTier = (G.achievements && G.achievements[ach.id]) || 0;
const maxTier = ach.tiers.length;
const isMaxed = currentTier >= maxTier;
// Current stat progress
let currentVal = 0;
if (ach.statKey === 'totalEarned') currentVal = G.totalEarned || 0;
else if (ach.statKey === 'cash') currentVal = G.cash || 0;
else if (ach.statKey === 'totalPersonnel') {
currentVal = Object.values(G.drugs || {}).reduce((sum, d) => sum + (d.suppliers || 0) + (d.workers || 0) + (d.dealers || 0), 0);
} else if (ach.statKey === 'unlockedCount') {
currentVal = Object.values(G.drugs || {}).filter(d => d.unlocked).length;
} else {
currentVal = (G.stats && G.stats[ach.statKey]) || 0;
}
const nextTierInfo = isMaxed ? ach.tiers[maxTier - 1] : ach.tiers[currentTier];
const nextReq = nextTierInfo.req;
const progressPct = isMaxed ? 100 : Math.min(100, Math.floor((currentVal / nextReq) * 100));
const canClaim = !isMaxed && currentVal >= nextReq;
// Star rating: ★★★★☆
let stars = '';
for (let i = 1; i <= maxTier; i++) {
stars += i <= currentTier ? '★' : '☆';
}
html += `
${ach.desc}
${ach.formatValue(currentVal)} / ${ach.formatValue(nextReq)}
${progressPct}%
Reward: ${nextTierInfo.rewardDesc}
${canClaim ? `
CLAIM REWARD
` : isMaxed ? `
MAX TIER REACHED
` : ''}
`;
});
dom.viewport.innerHTML = html;
}
// 4. OFFSHORE (PRESTIGE & SWISS PERK SHOP)
function renderOffshoreView() {
const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0);
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
const nextBonds = Math.max(0, bondsToEarn - totalLifetimeBonds);
const prodBoostPct = totalLifetimeBonds * 3;
const compoundLvl = (G.offshorePerks && G.offshorePerks['compound_interest']) || 0;
const salesRate = 4 + (compoundLvl * 2);
const salesBoostPct = totalLifetimeBonds * salesRate;
let html = `
SWISS ACCOUNTS & MONEY LAUNDERING
Launder Dirty Syndicate Cash
Reset dirty cash, lab equipment, and street dealers in exchange for permanent Clean Offshore Bonds .
Total lifetime bonds grant permanent passive boosts across all runs.
CLEAN BONDS GAINED UPON LAUNDERING:
+${nextBonds} Bonds
Career Volume: $${fmtNum(G.totalEarned)} • Lifetime Bonds: ${totalLifetimeBonds}
Current Passive Boost: +${prodBoostPct}% Production • +${salesBoostPct}% Sales Value
${nextBonds >= 1 ? 'Launder Cash & Reset Syndicate' : 'Need More Volume to Launder'}
SWISS BLACK MARKET PERKS
Spendable: ${G.cleanBonds || 0} Bonds
`;
OFFSHORE_PERKS.forEach(perk => {
const currentLvl = (G.offshorePerks && G.offshorePerks[perk.id]) || 0;
const maxLvl = perk.costs.length;
const isMaxed = currentLvl >= maxLvl;
const nextCost = isMaxed ? null : perk.costs[currentLvl];
const canBuy = !isMaxed && (G.cleanBonds || 0) >= nextCost;
html += `
${currentLvl > 0 ? perk.formatBonus(currentLvl) : 'Inactive'}
${isMaxed ? 'MAXED OUT' : `Invest ${nextCost} Bonds`}
`;
});
html += `
`;
dom.viewport.innerHTML = html;
}
// --- OPTIMISTIC CLIENT ACTIONS (Debounced Aggregation & Server Reconciliation) ---
function queueAction(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) {
const rawInfo = getRawPurchaseInfo(drugId);
if (G.cash >= rawInfo.totalCost && rawInfo.qty > 0) {
G.cash -= rawInfo.totalCost;
G.drugs[drugId].raw += rawInfo.qty;
queueAction({ type: 'buy_raw', drug_id: drugId, amount: rawInfo.qty });
updateLiveInventory();
}
}
function manufacture(drugId) {
const data = G.drugs[drugId];
if (data.raw >= 1) {
data.raw -= 1;
let yieldCount = 1;
if (G.skills['chem_3'] && Math.random() < 0.10) {
yieldCount = 3;
showToast('99.1% Purity Proc! 3x Yield!', false);
if (!G.stats) G.stats = {};
G.stats.purityProcs = (G.stats.purityProcs || 0) + 1;
}
data.product += yieldCount;
if (!G.stats) G.stats = {};
G.stats.totalProduced = (G.stats.totalProduced || 0) + yieldCount;
addHeat(0.5);
queueAction({ type: 'manufacture', drug_id: drugId, amount: 1 });
checkAchievements();
updateLiveInventory();
}
}
function sellManual(drugId, amount = 1) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const data = G.drugs[drugId];
const actual = Math.min(amount, Math.floor(data.product));
if (actual > 0) {
data.product -= actual;
const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier());
G.cash += profit;
G.totalEarned += profit;
if (!G.stats) G.stats = {};
G.stats.totalSold = (G.stats.totalSold || 0) + actual;
addHeat(actual * 0.8);
queueAction({ type: 'sell_manual', drug_id: drugId, amount: actual });
checkAchievements();
updateLiveInventory();
}
}
function hireSupplier(drugId) {
const supInfo = getUnitPurchaseInfo(drugId, 'suppliers');
if (G.cash >= supInfo.totalCost && supInfo.qty > 0) {
G.cash -= supInfo.totalCost;
if (!G.drugs[drugId].suppliers) G.drugs[drugId].suppliers = 0;
G.drugs[drugId].suppliers += supInfo.qty;
queueAction({ type: 'hire_supplier', drug_id: drugId, count: supInfo.qty });
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function hireWorker(drugId) {
const workInfo = getUnitPurchaseInfo(drugId, 'workers');
if (G.cash >= workInfo.totalCost && workInfo.qty > 0) {
G.cash -= workInfo.totalCost;
G.drugs[drugId].workers = (G.drugs[drugId].workers || 0) + workInfo.qty;
queueAction({ type: 'hire_worker', drug_id: drugId, count: workInfo.qty });
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function hireDealer(drugId) {
const dealInfo = getUnitPurchaseInfo(drugId, 'dealers');
if (G.cash >= dealInfo.totalCost && dealInfo.qty > 0) {
G.cash -= dealInfo.totalCost;
G.drugs[drugId].dealers = (G.drugs[drugId].dealers || 0) + dealInfo.qty;
queueAction({ type: 'hire_dealer', drug_id: drugId, count: dealInfo.qty });
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function claimAchievement(achId, tier) {
if (!G.achievements) G.achievements = {};
const currentTier = G.achievements[achId] || 0;
if (tier === currentTier + 1) {
G.achievements[achId] = tier;
queueAction({ type: 'claim_achievement', ach_id: achId, tier });
const achDef = ACHIEVEMENT_TIERS.find(a => a.id === achId);
const tierDef = achDef ? achDef.tiers[tier - 1] : null;
showToast(`🏆 Claimed: ${achDef.name} - ${tierDef ? tierDef.name : ''}!`);
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function buyOffshorePerk(perkId) {
if (!G.offshorePerks) G.offshorePerks = {};
const perk = OFFSHORE_PERKS.find(p => p.id === perkId);
if (!perk) return;
const currentLvl = G.offshorePerks[perkId] || 0;
if (currentLvl >= perk.costs.length) return;
const cost = perk.costs[currentLvl];
if ((G.cleanBonds || 0) >= cost) {
G.cleanBonds -= cost;
G.offshorePerks[perkId] = currentLvl + 1;
queueAction({ type: 'buy_offshore_perk', perk_id: perkId });
showToast(`Invested in ${perk.name} (Lvl ${currentLvl + 1})!`);
renderViewport();
updateLiveInventory();
}
}
function unlockDrug(drugId) {
const cost = getUnlockCost(drugId);
if (G.cash >= cost && !G.drugs[drugId].unlocked) {
G.cash -= cost;
G.drugs[drugId].unlocked = true;
// Check Procurement Pipeline perk
const starterUnits = (G.offshorePerks && G.offshorePerks['auto_contracts']) || 0;
if (starterUnits > 0) {
G.drugs[drugId].suppliers = (G.drugs[drugId].suppliers || 0) + starterUnits;
G.drugs[drugId].workers = (G.drugs[drugId].workers || 0) + starterUnits;
}
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
queueAction({ type: 'unlock_drug', drug_id: drugId });
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function blendRecipe(recipeId) {
const r = SYNTHESIS_RECIPES.find(x => x.id === recipeId);
const canCook = Object.entries(r.ingredients).every(([drugId, amt]) => G.drugs[drugId].product >= amt);
if (canCook) {
Object.entries(r.ingredients).forEach(([drugId, amt]) => {
G.drugs[drugId].product -= amt;
});
G.cash += r.sellPrice;
G.totalEarned += r.sellPrice;
addHeat(5);
queueAction({ type: 'blend_recipe', recipe_id: recipeId });
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
renderViewport();
updateLiveInventory();
}
}
function buySkill(skillId) {
const s = SKILL_TREE.find(x => x.id === skillId);
if (G.cash >= s.cost && !G.skills[skillId]) {
G.cash -= s.cost;
G.skills[skillId] = true;
queueAction({ type: 'buy_skill', skill_id: skillId });
showToast(`Skill Activated: ${s.name}`);
renderViewport();
updateLiveInventory();
}
}
async function launderMoney() {
const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0);
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
const gain = Math.max(0, bondsToEarn - totalLifetimeBonds);
if (gain < 1) return;
const confirmed = await showConfirmModal(
'LAUNDER SYNDICATE ASSETS',
`Launder all operations for +${gain} Clean Offshore Bonds ? Your dirty cash and current equipment will reset, but all permanent Swiss perks, trophies, lifetime bond multipliers and achievements are retained.`,
{ okText: 'Launder & Reset', cancelText: 'Abort', icon: 'game-icons:tied-scroll' }
);
if (confirmed) {
queueAction({ type: 'launder_prestige' });
G.totalCleanBonds = totalLifetimeBonds + gain;
G.cleanBonds = (G.cleanBonds || 0) + gain;
if (!G.stats) G.stats = {};
G.stats.prestigeCount = (G.stats.prestigeCount || 0) + 1;
let startCash = 50.0 + (G.totalCleanBonds * 25.0);
const swissTier = (G.achievements && G.achievements['swiss_kingpin']) || 0;
if (swissTier >= 1) startCash += 250.0;
if (swissTier >= 2) startCash += 2500.0;
if (swissTier >= 3) startCash += 25000.0;
if (swissTier >= 4) startCash += 250000.0;
G.cash = startCash;
G.heat = 0.0;
const starterUnits = (G.offshorePerks && G.offshorePerks['auto_contracts']) || 0;
DRUG_CATALOG.forEach(d => {
G.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0.0,
product: 0.0,
suppliers: d.unlockedDefault ? starterUnits : 0,
workers: d.unlockedDefault ? starterUnits : 0,
dealers: 0
};
});
checkAchievements();
showToast(`Laundered! You now hold ${G.cleanBonds} Spendable Bonds ($${startCash.toLocaleString()} starting cash).`);
renderViewport();
updateLiveInventory();
}
}
// --- POLICE HEAT & RAIDS ---
function addHeat(amount) {
let riseMod = G.skills['smug_1'] ? 0.65 : 1.0;
G.heat = Math.min(100.0, G.heat + (amount * riseMod));
}
function checkRaidEvent() {
if (G.heat >= 85 && Math.random() < 0.25) {
triggerRaidModal();
}
}
function triggerRaidModal() {
const bribeCost = Math.max(100, Math.floor(G.cash * 0.15 * (G.skills['smug_2'] ? 0.5 : 1.0)));
dom.raidBribeCost.textContent = bribeCost.toLocaleString();
dom.raidModal.classList.add('active');
document.getElementById('btn-raid-bribe').onclick = () => {
if (G.cash >= bribeCost) {
G.cash -= bribeCost;
G.heat = 0.0;
queueAction({ type: 'bribe_police' });
dom.raidModal.classList.remove('active');
showToast('Sheriff bribed. Heat cleared.');
updateLiveInventory();
} else {
showToast('Insufficient cash for bribe!', true);
}
};
document.getElementById('btn-raid-stash').onclick = () => {
G.heat = 20.0;
queueAction({ type: 'flush_stash' });
dom.raidModal.classList.remove('active');
showToast('Stash flushed & secured. Heat diminished.');
updateLiveInventory();
};
document.getElementById('btn-raid-shootout').onclick = () => {
dom.raidModal.classList.remove('active');
if (Math.random() < 0.5) {
const reward = Math.floor(G.cash * 0.5);
G.cash += reward;
G.heat = 0.0;
showToast(`Broke through blockade! Seized +$${reward.toLocaleString()}!`);
} else {
DRUG_CATALOG.forEach(d => {
G.drugs[d.id].product = Math.floor(G.drugs[d.id].product * 0.7);
});
G.heat = 30.0;
showToast('Agents seized 30% of stored product!', true);
}
updateLiveInventory();
};
}
// --- ACHIEVEMENTS CHECK & BADGE UPDATE ---
function checkAchievements() {
let unreadCount = 0;
ACHIEVEMENT_TIERS.forEach(ach => {
const currentTier = (G.achievements && G.achievements[ach.id]) || 0;
const maxTier = ach.tiers.length;
if (currentTier < maxTier) {
let currentVal = 0;
if (ach.statKey === 'totalEarned') currentVal = G.totalEarned || 0;
else if (ach.statKey === 'cash') currentVal = G.cash || 0;
else if (ach.statKey === 'totalPersonnel') {
currentVal = Object.values(G.drugs || {}).reduce((sum, d) => sum + (d.suppliers || 0) + (d.workers || 0) + (d.dealers || 0), 0);
} else if (ach.statKey === 'unlockedCount') {
currentVal = Object.values(G.drugs || {}).filter(d => d.unlocked).length;
} else {
currentVal = (G.stats && G.stats[ach.statKey]) || 0;
}
const nextReq = ach.tiers[currentTier].req;
if (currentVal >= nextReq) {
unreadCount += 1;
}
}
});
if (dom.trophyBadge) {
if (unreadCount > 0) {
dom.trophyBadge.textContent = unreadCount;
dom.trophyBadge.style.display = 'block';
} else {
dom.trophyBadge.style.display = 'none';
}
}
}
// --- LIGHTWEIGHT LOCAL TICK (Smooth 60FPS UI feedback between server syncs) ---
function clientTick(deltaSec) {
let earnedThisTick = 0;
const prodMulti = getProductionMultiplier();
const sellMulti = getSellPriceMultiplier();
const dealerSpeed = getDealerSpeedMultiplier();
const supMulti = getSupplierSpeedMultiplier();
const workMulti = getWorkerSpeedMultiplier();
const allTierSupM = getAllTierMilestoneMultiplier('suppliers');
const allTierWorkM = getAllTierMilestoneMultiplier('workers');
const allTierDealM = getAllTierMilestoneMultiplier('dealers');
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id];
if (!data || !data.unlocked) return;
// Auto Supply
const suppliers = data.suppliers || 0;
if (suppliers > 0) {
const supMilestoneM = getUnitMilestoneMultiplier(suppliers);
const supplyRate = d.supplyRate || 1.0;
const toSupply = suppliers * supplyRate * prodMulti * supMulti * supMilestoneM * allTierSupM * deltaSec;
data.raw = data.raw + toSupply;
if (!G.stats) G.stats = {};
G.stats.totalSupplied = (G.stats.totalSupplied || 0) + toSupply;
}
// Auto Production
const workers = data.workers || 0;
if (workers > 0 && data.raw > 0) {
const workMilestoneM = getUnitMilestoneMultiplier(workers);
const prodPerSec = workers * d.produceRate * prodMulti * workMulti * workMilestoneM * allTierWorkM;
const toMake = Math.min(data.raw, prodPerSec * deltaSec);
data.raw = Math.max(0, data.raw - toMake);
data.product = data.product + toMake;
if (!G.stats) G.stats = {};
G.stats.totalProduced = (G.stats.totalProduced || 0) + toMake;
}
// Auto Sales
const dealers = data.dealers || 0;
if (dealers > 0 && data.product > 0) {
const dealMilestoneM = getUnitMilestoneMultiplier(dealers);
const sellPerSec = dealers * d.sellRate * dealerSpeed * dealMilestoneM * allTierDealM;
const toSell = Math.min(data.product, sellPerSec * deltaSec);
data.product = Math.max(0, data.product - toSell);
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
G.cash += revenue;
G.totalEarned += revenue;
earnedThisTick += revenue;
if (!G.stats) G.stats = {};
G.stats.totalSold = (G.stats.totalSold || 0) + toSell;
}
});
if (G.heat > 0) {
G.heat = Math.max(0.0, G.heat - (0.5 * deltaSec));
}
if (deltaSec > 0) {
cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15;
}
// Direct DOM text updates
updateLiveInventory();
}
function updateLiveInventory() {
dom.cash.textContent = fmtNum(G.cash);
dom.rate.textContent = fmtNum(cashRateTracker);
dom.bonds.textContent = fmtInt(G.cleanBonds || 0);
const totalLifetimeBonds = Math.max(G.totalCleanBonds || 0, G.cleanBonds || 0);
const compoundLvl = (G.offshorePerks && G.offshorePerks['compound_interest']) || 0;
const bondRate = 4 + (compoundLvl * 2);
dom.multiplier.textContent = fmtInt(totalLifetimeBonds * bondRate);
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
const prodMulti = getProductionMultiplier();
const supMulti = getSupplierSpeedMultiplier();
const workMulti = getWorkerSpeedMultiplier();
const allTierSupM = getAllTierMilestoneMultiplier('suppliers');
const allTierWorkM = getAllTierMilestoneMultiplier('workers');
let stashChipsHtml = '';
// Surgically update raw, product, buttons, and stash chips
DRUG_CATALOG.forEach(d => {
const drugData = G.drugs[d.id];
if (!drugData) return;
// Populate quick stash overview for owned/unlocked products
if (drugData.unlocked || drugData.product > 0 || drugData.raw > 0) {
stashChipsHtml += `
${d.name.split(' ')[0]}:
${fmtNum(drugData.product)}g
`;
}
// Live text update for raw & product
const rawEl = document.getElementById(`val-raw-${d.id}`);
if (rawEl) rawEl.textContent = fmtNum(drugData.raw);
const prodEl = document.getElementById(`val-prod-${d.id}`);
if (prodEl) prodEl.textContent = `${fmtNum(drugData.product)}g`;
const streetProdEl = document.getElementById(`val-prod-street-${d.id}`);
if (streetProdEl) streetProdEl.textContent = `${fmtNum(drugData.product)}g`;
const supplierCountEl = document.getElementById(`val-supplier-count-${d.id}`);
if (supplierCountEl) supplierCountEl.textContent = `${d.supplierName}s: ${drugData.suppliers || 0}`;
const autoSupplyEl = document.getElementById(`val-auto-supply-${d.id}`);
if (autoSupplyEl) {
const suppliers = drugData.suppliers || 0;
const supMilestoneM = getUnitMilestoneMultiplier(suppliers);
const currentSupplySpeed = suppliers * (d.supplyRate || 1.0) * prodMulti * supMulti * supMilestoneM * allTierSupM;
autoSupplyEl.textContent = `(+${fmtNum(currentSupplySpeed)} r/s)`;
autoSupplyEl.style.color = suppliers > 0 ? '#38bdf8' : '#8b949e';
}
const workerCountEl = document.getElementById(`val-worker-count-${d.id}`);
if (workerCountEl) workerCountEl.textContent = `${d.workerName}s: ${drugData.workers || 0}`;
const autoProdEl = document.getElementById(`val-auto-prod-${d.id}`);
if (autoProdEl) {
const workers = drugData.workers || 0;
const workMilestoneM = getUnitMilestoneMultiplier(workers);
const currentSpeed = workers * d.produceRate * prodMulti * workMulti * workMilestoneM * allTierWorkM;
autoProdEl.textContent = `(${fmtNum(currentSpeed)}g/s)`;
autoProdEl.style.color = (workers > 0 && drugData.raw > 0) ? '#4ade80' : '#8b949e';
}
// Multi-buy pricing & button states
const rawInfo = getRawPurchaseInfo(d.id);
const supInfo = getUnitPurchaseInfo(d.id, 'suppliers');
const workInfo = getUnitPurchaseInfo(d.id, 'workers');
const dealInfo = getUnitPurchaseInfo(d.id, 'dealers');
const btnBuyRaw = document.getElementById(`btn-buy-raw-${d.id}`);
if (btnBuyRaw) {
btnBuyRaw.disabled = (G.cash < rawInfo.totalCost);
btnBuyRaw.innerHTML = `Buy Raw (+${rawInfo.qty})$${rawInfo.totalCost.toLocaleString()} `;
}
const btnHireSupplier = document.getElementById(`btn-hire-supplier-${d.id}`);
if (btnHireSupplier) {
btnHireSupplier.disabled = (G.cash < supInfo.totalCost);
btnHireSupplier.innerHTML = `Hire ${d.supplierName} (+${supInfo.qty})$${supInfo.totalCost.toLocaleString()} `;
}
const btnMake = document.getElementById(`btn-make-${d.id}`);
if (btnMake) btnMake.disabled = (drugData.raw < 1);
const btnSell = document.getElementById(`btn-sell-${d.id}`);
if (btnSell) btnSell.disabled = (drugData.product < 1);
const btnHireWorker = document.getElementById(`btn-hire-worker-${d.id}`);
if (btnHireWorker) {
btnHireWorker.disabled = (G.cash < workInfo.totalCost);
btnHireWorker.innerHTML = `Hire ${d.workerName} (+${workInfo.qty})$${workInfo.totalCost.toLocaleString()} `;
}
const btnHireDealer = document.getElementById(`btn-hire-dealer-${d.id}`);
if (btnHireDealer) {
btnHireDealer.disabled = (G.cash < dealInfo.totalCost);
btnHireDealer.innerHTML = `Hire ${d.dealerName} (+${dealInfo.qty})$${dealInfo.totalCost.toLocaleString()} `;
}
const btnSell10x = document.getElementById(`btn-sell-10x-${d.id}`);
if (btnSell10x) btnSell10x.disabled = (drugData.product < 10);
const btnUnlock = document.getElementById(`btn-unlock-${d.id}`);
if (btnUnlock) btnUnlock.disabled = (G.cash < getUnlockCost(d.id));
});
if (dom.quickStash) {
dom.quickStash.innerHTML = stashChipsHtml || 'No active stash ';
}
}
// --- 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 (act.type === 'claim_achievement') {
if (!state.achievements) state.achievements = {};
state.achievements[act.ach_id] = act.tier;
return;
}
if (act.type === 'buy_offshore_perk') {
if (!state.offshorePerks) state.offshorePerks = {};
const curLvl = state.offshorePerks[act.perk_id] || 0;
state.offshorePerks[act.perk_id] = curLvl + 1;
return;
}
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;
const baseCost = dCfg.supplierCost || 75;
for (let i = 0; i < count; i++) {
const currentCount = state.drugs[dId].suppliers || 0;
const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), 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;
const baseCost = dCfg.workerCost;
for (let i = 0; i < count; i++) {
const currentCount = state.drugs[dId].workers || 0;
const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), 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;
const baseCost = dCfg.dealerCost;
for (let i = 0; i < count; i++) {
const currentCount = state.drugs[dId].dealers || 0;
const cost = Math.floor(baseCost * Math.pow(getPriceScaling(), 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;
}
const actionsToSend = [...pendingActions];
pendingActions = [];
inFlightActions = [...actionsToSend];
actionSeq += 1;
const currentSeq = actionSeq;
isSyncing = true;
if (API.getToken()) {
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,
buyMode: G.buyMode,
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
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
let lastFrameTime = performance.now();
function gameLoop(now) {
const delta = (now - lastFrameTime) / 1000;
lastFrameTime = now;
if (delta > 0 && delta < 5) {
clientTick(delta);
}
requestAnimationFrame(gameLoop);
}
// Background heartbeat (every 10 seconds): syncs idle accrual when player is not actively clicking
setInterval(() => {
if (!syncDebounceTimer && !isSyncing) {
triggerServerSync();
}
}, 10000);
// Clock updates
setInterval(() => {
const d = new Date();
dom.clock.textContent = d.toTimeString().substring(0, 5);
}, 1000);
// Periodic raid check (every 15 seconds)
setInterval(checkRaidEvent, 15000);
// --- INITIALIZATION & EVENTS ---
document.addEventListener('DOMContentLoaded', async () => {
const username = API.getUsername();
if (username) {
dom.userBadge.textContent = `[${username}]`;
}
// Load initial authoritative save from server if logged in
let loaded = false;
if (API.getToken()) {
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: 'lab' };
loaded = true;
}
}
if (!loaded) {
const local = localStorage.getItem('el_puro_local_save');
if (local) {
try {
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local), activeTab: 'lab' };
} catch (e) {
console.warn('Corrupt local save, using initial state');
}
}
}
// Setup navigation tabs
document.querySelectorAll('.nav-tab').forEach(b => {
b.addEventListener('click', () => setTab(b.dataset.tab));
});
// Setup Buy Multiplier Pill Buttons
document.querySelectorAll('.btn-multi-pill').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
G.buyMode = btn.dataset.mode;
renderViewport();
updateLiveInventory();
});
});
// Auth Modal handling
dom.userBadge.addEventListener('click', () => dom.authModal.classList.add('active'));
document.getElementById('btn-close-auth').addEventListener('click', () => dom.authModal.classList.remove('active'));
document.getElementById('btn-login-submit').addEventListener('click', async () => {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
if (!u || !p) return showToast('Enter username and password', true);
try {
const res = await API.login(u, p);
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Connected: Welcome ${res.username}`);
// Fetch server authoritative state
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: G.activeTab };
renderViewport();
}
} catch (e) {
showToast(e.message, true);
}
});
document.getElementById('btn-register-submit').addEventListener('click', async () => {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
const hp = document.getElementById('auth-website-hp').value;
if (!u || !p) return showToast('Enter username and password', true);
try {
const res = await API.register(u, p, hp);
dom.userBadge.textContent = `[${res.username}]`;
dom.authModal.classList.remove('active');
showToast(`Syndicate registered: ${res.username}`);
// Fetch clean server initialized state
const remote = await API.loadRemoteSave();
if (remote) {
G = { ...INITIAL_GAME_STATE, ...remote, activeTab: G.activeTab };
renderViewport();
}
} catch (e) {
showToast(e.message, true);
}
});
// Confirmation & Alert Modal Button Handlers
const btnConfirmCancel = document.getElementById('btn-confirm-cancel');
const btnConfirmOk = document.getElementById('btn-confirm-ok');
if (btnConfirmCancel) btnConfirmCancel.addEventListener('click', () => closeModal(false));
if (btnConfirmOk) btnConfirmOk.addEventListener('click', () => closeModal(true));
// Settings Modal Handling
if (dom.btnOpenSettings && dom.settingsModal) {
dom.btnOpenSettings.addEventListener('click', () => {
// Update radio to match active userNotation
const radios = document.querySelectorAll('input[name="notation-choice"]');
radios.forEach(r => {
r.checked = (r.value === userNotation);
});
updateSettingsPreview();
dom.settingsModal.classList.add('active');
});
}
const btnCloseSettings = document.getElementById('btn-close-settings');
if (btnCloseSettings && dom.settingsModal) {
btnCloseSettings.addEventListener('click', () => {
const selected = document.querySelector('input[name="notation-choice"]:checked');
if (selected) {
userNotation = selected.value;
localStorage.setItem('el_puro_notation', userNotation);
}
dom.settingsModal.classList.remove('active');
renderViewport();
updateLiveInventory();
showToast(`Number display updated: ${userNotation === 'scientific' ? 'Scientific' : 'Standard Idle'}`);
});
}
document.querySelectorAll('input[name="notation-choice"]').forEach(r => {
r.addEventListener('change', () => {
userNotation = r.value;
localStorage.setItem('el_puro_notation', userNotation);
updateSettingsPreview();
renderViewport();
updateLiveInventory();
});
});
function updateSettingsPreview() {
const previewEl = document.getElementById('settings-preview-val');
if (previewEl) {
previewEl.textContent = `$1,234,567.89 ➔ $${fmtNum(1234567.89)} | 5.25B ➔ ${fmtNum(5250000000)}`;
}
}
renderViewport();
checkAchievements();
updateLiveInventory();
requestAnimationFrame(gameLoop);
});