1690 lines
70 KiB
JavaScript
1690 lines
70 KiB
JavaScript
// 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 = `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 4px; display: flex; justify-content: space-between;">
|
|
<span>ACTIVE LABORATORIES</span>
|
|
<span>SYNTHESIS LEVEL: ${G.cleanBonds}</span>
|
|
</div>
|
|
`;
|
|
|
|
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 += `
|
|
<div class="card locked">
|
|
<div class="card-header">
|
|
<div class="card-title-group">
|
|
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
|
|
<iconify-icon icon="${d.icon}"></iconify-icon>
|
|
</div>
|
|
<div>
|
|
<div class="card-title">${d.name}</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono);">CLASSIFIED FORMULA</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="unlock-overlay-box">
|
|
<button id="btn-unlock-${d.id}" class="btn-unlock" onclick="unlockDrug('${d.id}')" ${G.cash < unlockCost ? 'disabled' : ''}>
|
|
<iconify-icon icon="lucide:lock-open"></iconify-icon>
|
|
Acquire Formula ($${unlockCost.toLocaleString()})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 += `
|
|
<div class="card" style="border-left: 4px solid ${d.color};">
|
|
<div class="card-header">
|
|
<div class="card-title-group">
|
|
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
|
|
<iconify-icon icon="${d.icon}"></iconify-icon>
|
|
</div>
|
|
<div>
|
|
<div class="card-title">${d.name}</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono); display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">
|
|
<span id="val-supplier-count-${d.id}">${d.supplierName}s: ${suppliers}</span>
|
|
<span class="milestone-badge" title="Next 2x speed at ${nextSupM}">M: ${suppliers}/${nextSupM} (${supMilestoneM}x)</span>
|
|
<span id="val-auto-supply-${d.id}" style="color: ${suppliers > 0 ? '#38bdf8' : '#8b949e'};">
|
|
(+${fmtNum(autoSupplySpeed)} r/s)
|
|
</span>
|
|
<span>•</span>
|
|
<span id="val-worker-count-${d.id}">${d.workerName}s: ${workers}</span>
|
|
<span class="milestone-badge" title="Next 2x speed at ${nextWorkM}">M: ${workers}/${nextWorkM} (${workMilestoneM}x)</span>
|
|
<span id="val-auto-prod-${d.id}" style="color: ${workers > 0 ? '#4ade80' : '#8b949e'};">
|
|
(${fmtNum(autoProdSpeed)}g/s)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="font-family: var(--font-mono); font-size: 0.65rem; color: #8b949e; text-align: right; background: #0c1117; padding: 4px 8px; border-radius: 6px; border: 1px solid #1f2937;">
|
|
<span>Auto-Refines</span><br>
|
|
<span style="color: #4ade80;">1 Raw ➔ 1g</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-stats-row">
|
|
<div class="stat-item">
|
|
<span class="stat-label">RAW SUPPLY (${d.rawName.toUpperCase()})</span>
|
|
<span class="stat-value" id="val-raw-${d.id}">${fmtNum(data.raw)}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">READY PRODUCT (${d.productName.toUpperCase()})</span>
|
|
<span class="stat-value" id="val-prod-${d.id}">${fmtNum(data.product)}g</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-grid-actions">
|
|
<button id="btn-buy-raw-${d.id}" class="btn-tactical" onclick="buyRaw('${d.id}')" ${G.cash < rawInfo.totalCost ? 'disabled' : ''}>
|
|
Buy Raw (+${rawInfo.qty})
|
|
<span class="btn-subtext">$${rawInfo.totalCost.toLocaleString()}</span>
|
|
</button>
|
|
<button id="btn-make-${d.id}" class="btn-tactical btn-primary-action" onclick="manufacture('${d.id}')" ${data.raw < 1 ? 'disabled' : ''}>
|
|
Synthesize
|
|
<span class="btn-subtext">1 Raw -> 1g</span>
|
|
</button>
|
|
<button id="btn-sell-${d.id}" class="btn-tactical btn-sell-action" onclick="sellManual('${d.id}')" ${data.product < 1 ? 'disabled' : ''}>
|
|
Quick Deal
|
|
<span class="btn-subtext">+$${Math.floor(d.sellPrice * getSellPriceMultiplier()).toLocaleString()}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="btn-grid-auto">
|
|
<button id="btn-hire-supplier-${d.id}" class="btn-tactical" onclick="hireSupplier('${d.id}')" ${G.cash < supInfo.totalCost ? 'disabled' : ''}>
|
|
Hire ${d.supplierName} (+${supInfo.qty})
|
|
<span class="btn-subtext">$${supInfo.totalCost.toLocaleString()}</span>
|
|
</button>
|
|
<button id="btn-hire-worker-${d.id}" class="btn-tactical" onclick="hireWorker('${d.id}')" ${G.cash < workInfo.totalCost ? 'disabled' : ''}>
|
|
Hire ${d.workerName} (+${workInfo.qty})
|
|
<span class="btn-subtext">$${workInfo.totalCost.toLocaleString()}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
dom.viewport.innerHTML = html;
|
|
}
|
|
|
|
// 2. STREETS VIEW (Dealers & Blends)
|
|
function renderStreetsView() {
|
|
let html = `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 4px;">
|
|
STREET DISTRIBUTION & DEALER NETWORK
|
|
</div>
|
|
`;
|
|
|
|
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 += `
|
|
<div class="card" style="border-left: 4px solid ${d.color};">
|
|
<div class="card-header">
|
|
<div class="card-title-group">
|
|
<div class="drug-icon-box" style="border-color: ${d.color}; color: ${d.color}">
|
|
<iconify-icon icon="${d.icon}"></iconify-icon>
|
|
</div>
|
|
<div>
|
|
<div class="card-title">${d.name}</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary); font-family: var(--font-mono); display: flex; align-items: center; gap: 6px;">
|
|
<span>${d.dealerName}s: ${dealers}</span>
|
|
<span class="milestone-badge" title="Next 2x speed at ${nextDealM}">M: ${dealers}/${nextDealM} (${dealMilestoneM}x)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="text-align: right; font-family: var(--font-mono); font-size: 0.8rem; color: var(--brand-gold);">
|
|
$${unitPrice.toLocaleString()}/g
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-stats-row">
|
|
<div class="stat-item">
|
|
<span class="stat-label">In Stash</span>
|
|
<span class="stat-value" id="val-prod-street-${d.id}">${fmtNum(data.product)}g</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Deal Speed</span>
|
|
<span class="stat-value">${fmtNum(dealSpeed)}/s</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-grid-auto">
|
|
<button id="btn-hire-dealer-${d.id}" class="btn-tactical btn-sell-action" onclick="hireDealer('${d.id}')" ${G.cash < dealInfo.totalCost ? 'disabled' : ''}>
|
|
Hire ${d.dealerName} (+${dealInfo.qty})
|
|
<span class="btn-subtext">$${dealInfo.totalCost.toLocaleString()}</span>
|
|
</button>
|
|
<button id="btn-sell-10x-${d.id}" class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
|
|
Sell 10x Stash
|
|
<span class="btn-subtext">+$${(unitPrice * Math.min(10, Math.floor(data.product))).toLocaleString()}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
// RECIPE SYNTHESIS SECTION
|
|
html += `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--brand-gold); margin: 14px 0 4px; display: flex; align-items: center; gap: 6px;">
|
|
<iconify-icon icon="game-icons:cauldron"></iconify-icon>
|
|
DESIGNER COCKTAILS & BLENDS
|
|
</div>
|
|
`;
|
|
|
|
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 `<span style="color: ${has >= amt ? '#22c55e' : '#ef4444'}">${amt}g ${drugDef.name} (${fmtNum(has)}g)</span>`;
|
|
}).join(' + ');
|
|
|
|
html += `
|
|
<div class="card" style="border: 1px solid #384654;">
|
|
<div class="card-header">
|
|
<div class="card-title-group">
|
|
<div class="drug-icon-box" style="color: var(--brand-gold);">
|
|
<iconify-icon icon="${r.icon}"></iconify-icon>
|
|
</div>
|
|
<div>
|
|
<div class="card-title">${r.name}</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary);">${r.description}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; background: #121820; padding: 6px 10px; border-radius: 6px; margin-bottom: 8px;">
|
|
Recipe: ${ingrLabels}
|
|
</div>
|
|
<button class="btn-tactical btn-sell-action" onclick="blendRecipe('${r.id}')" ${!canCook ? 'disabled' : ''}>
|
|
Cook & Distribute Blend
|
|
<span class="btn-subtext">Instant Payout: +$${r.sellPrice.toLocaleString()}</span>
|
|
</button>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
dom.viewport.innerHTML = html;
|
|
}
|
|
|
|
// 3. SKILL TREE (SPIDERWEB)
|
|
function renderSkillTreeView() {
|
|
let html = `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 6px;">
|
|
UNDERWORLD TALENT SPIDERWEB
|
|
</div>
|
|
`;
|
|
|
|
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 += `
|
|
<div style="font-family: var(--font-mono); font-size: 0.75rem; color: ${b.color}; margin: 8px 0 4px; display: flex; align-items: center; gap: 6px;">
|
|
<iconify-icon icon="${b.icon}"></iconify-icon>
|
|
${b.name.toUpperCase()}
|
|
</div>
|
|
`;
|
|
|
|
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 += `
|
|
<div class="card ${isUnlocked ? '' : prereqsMet ? '' : 'locked'}" style="margin-bottom: 8px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px;">
|
|
<div>
|
|
<div style="font-weight: bold; font-size: 0.85rem; color: ${isUnlocked ? b.color : '#fff'};">
|
|
${s.name} ${isUnlocked ? '✓' : ''}
|
|
</div>
|
|
<div style="font-size: 0.72rem; color: var(--text-secondary);">${s.desc}</div>
|
|
</div>
|
|
<div style="font-family: var(--font-mono); font-size: 0.8rem; color: var(--brand-gold);">
|
|
${isUnlocked ? '<span style="color:#22c55e;">ACQUIRED</span>' : '$' + s.cost.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
${!isUnlocked ? `
|
|
<button class="btn-tactical" onclick="buySkill('${s.id}')" ${G.cash < s.cost || !prereqsMet ? 'disabled' : ''}>
|
|
${!prereqsMet ? 'Requires Prior Link' : 'Unlock Node ($' + s.cost.toLocaleString() + ')'}
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
});
|
|
});
|
|
|
|
dom.viewport.innerHTML = html;
|
|
}
|
|
|
|
// 3. TROPHIES VIEW (Multi-Tiered Achievements)
|
|
function renderTrophiesView() {
|
|
let html = `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center;">
|
|
<span>SYNDICATE TROPHIES & CAREER MILESTONES</span>
|
|
<span style="color: var(--brand-gold);">CAREER VOL: $${fmtNum(G.totalEarned)}</span>
|
|
</div>
|
|
`;
|
|
|
|
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 += `
|
|
<div class="trophy-card ${canClaim ? 'ready' : ''}">
|
|
<div class="trophy-header">
|
|
<div class="trophy-title-group">
|
|
<div class="trophy-icon-box">
|
|
<iconify-icon icon="${ach.icon}"></iconify-icon>
|
|
</div>
|
|
<div>
|
|
<div class="trophy-title">${ach.name}</div>
|
|
<div class="trophy-tier-name" style="color: ${currentTier > 0 ? 'var(--brand-gold)' : '#8b949e'};">
|
|
${currentTier > 0 ? ach.tiers[currentTier - 1].name : 'Unranked'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="trophy-stars" title="Tier ${currentTier}/${maxTier}">
|
|
${stars}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="font-size: 0.72rem; color: var(--text-secondary); line-height: 1.3;">
|
|
${ach.desc}
|
|
</div>
|
|
|
|
<div class="trophy-progress-track">
|
|
<div class="trophy-progress-fill" style="width: ${progressPct}%;"></div>
|
|
</div>
|
|
|
|
<div style="display: flex; justify-content: space-between; font-size: 0.68rem; font-family: var(--font-mono); color: var(--text-muted);">
|
|
<span>${ach.formatValue(currentVal)} / ${ach.formatValue(nextReq)}</span>
|
|
<span style="color: ${canClaim ? '#22c55e' : '#9ca3af'};">${progressPct}%</span>
|
|
</div>
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4px;">
|
|
<div style="font-family: var(--font-mono); font-size: 0.7rem; color: var(--neon-cyan);">
|
|
Reward: ${nextTierInfo.rewardDesc}
|
|
</div>
|
|
${canClaim ? `
|
|
<button class="btn-claim-reward" onclick="claimAchievement('${ach.id}', ${currentTier + 1})">
|
|
CLAIM REWARD
|
|
</button>
|
|
` : isMaxed ? `
|
|
<span style="font-family: var(--font-mono); font-size: 0.7rem; color: #22c55e; font-weight: bold;">MAX TIER REACHED</span>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
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 = `
|
|
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); margin-bottom: 6px;">
|
|
SWISS ACCOUNTS & MONEY LAUNDERING
|
|
</div>
|
|
|
|
<div class="card" style="border: 2px solid var(--neon-cyan); text-align: center; padding: 18px 14px;">
|
|
<iconify-icon icon="game-icons:tied-scroll" style="font-size: 2.8rem; color: var(--neon-cyan);"></iconify-icon>
|
|
<div style="font-size: 1.15rem; font-weight: bold; margin-top: 6px;">Launder Dirty Syndicate Cash</div>
|
|
<p style="font-size: 0.76rem; color: var(--text-secondary); margin: 6px 0 12px; line-height: 1.4;">
|
|
Reset dirty cash, lab equipment, and street dealers in exchange for permanent <b>Clean Offshore Bonds</b>.
|
|
Total lifetime bonds grant permanent passive boosts across all runs.
|
|
</p>
|
|
<div style="background: #090e13; border: 1px solid #1f2a36; border-radius: 8px; padding: 10px; margin-bottom: 12px; font-family: var(--font-mono);">
|
|
<div style="font-size: 0.7rem; color: var(--text-secondary);">CLEAN BONDS GAINED UPON LAUNDERING:</div>
|
|
<div style="font-size: 1.8rem; font-weight: 800; color: var(--neon-cyan);">+${nextBonds} Bonds</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-muted); margin-top: 2px;">
|
|
Career Volume: $${fmtNum(G.totalEarned)} • Lifetime Bonds: ${totalLifetimeBonds}
|
|
</div>
|
|
<div style="font-size: 0.68rem; color: #4ade80; margin-top: 4px;">
|
|
Current Passive Boost: +${prodBoostPct}% Production • +${salesBoostPct}% Sales Value
|
|
</div>
|
|
</div>
|
|
<button class="btn-tactical btn-sell-action" style="padding: 10px; font-size: 0.85rem;" onclick="launderMoney()" ${nextBonds < 1 ? 'disabled' : ''}>
|
|
${nextBonds >= 1 ? 'Launder Cash & Reset Syndicate' : 'Need More Volume to Launder'}
|
|
</button>
|
|
</div>
|
|
|
|
<div style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--neon-cyan); margin: 16px 0 6px; display: flex; justify-content: space-between; align-items: center;">
|
|
<span style="display: flex; align-items: center; gap: 6px;">
|
|
<iconify-icon icon="game-icons:safe"></iconify-icon>
|
|
SWISS BLACK MARKET PERKS
|
|
</span>
|
|
<span style="color: var(--brand-gold);">Spendable: ${G.cleanBonds || 0} Bonds</span>
|
|
</div>
|
|
<div class="perk-grid">
|
|
`;
|
|
|
|
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 += `
|
|
<div class="perk-card">
|
|
<div class="perk-card-header">
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<iconify-icon icon="${perk.icon}" style="font-size: 1.4rem; color: var(--neon-cyan);"></iconify-icon>
|
|
<div>
|
|
<div style="font-weight: bold; font-size: 0.82rem; color: #fff;">${perk.name}</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary);">${perk.desc}</div>
|
|
</div>
|
|
</div>
|
|
<span class="perk-lvl-badge">${currentLvl > 0 ? `LVL ${currentLvl}/${maxLvl}` : 'UNLOCKED 0'}</span>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 4px;">
|
|
<span style="font-family: var(--font-mono); font-size: 0.7rem; color: #22c55e;">
|
|
${currentLvl > 0 ? perk.formatBonus(currentLvl) : 'Inactive'}
|
|
</span>
|
|
<button class="btn-perk-buy" onclick="buyOffshorePerk('${perk.id}')" ${!canBuy ? 'disabled' : ''}>
|
|
${isMaxed ? 'MAXED OUT' : `Invest ${nextCost} Bonds`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
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 <b style="color: var(--neon-cyan);">+${gain} Clean Offshore Bonds</b>?<br><br>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 += `
|
|
<div class="stash-chip" title="${d.name}">
|
|
<span class="stash-chip-dot" style="background: ${d.color};"></span>
|
|
<span>${d.name.split(' ')[0]}:</span>
|
|
<span class="stash-chip-val" style="color: ${drugData.product > 0 ? '#fff' : '#6b7280'}">${fmtNum(drugData.product)}g</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 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})<span class="btn-subtext">$${rawInfo.totalCost.toLocaleString()}</span>`;
|
|
}
|
|
|
|
const btnHireSupplier = document.getElementById(`btn-hire-supplier-${d.id}`);
|
|
if (btnHireSupplier) {
|
|
btnHireSupplier.disabled = (G.cash < supInfo.totalCost);
|
|
btnHireSupplier.innerHTML = `Hire ${d.supplierName} (+${supInfo.qty})<span class="btn-subtext">$${supInfo.totalCost.toLocaleString()}</span>`;
|
|
}
|
|
|
|
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})<span class="btn-subtext">$${workInfo.totalCost.toLocaleString()}</span>`;
|
|
}
|
|
|
|
const btnHireDealer = document.getElementById(`btn-hire-dealer-${d.id}`);
|
|
if (btnHireDealer) {
|
|
btnHireDealer.disabled = (G.cash < dealInfo.totalCost);
|
|
btnHireDealer.innerHTML = `Hire ${d.dealerName} (+${dealInfo.qty})<span class="btn-subtext">$${dealInfo.totalCost.toLocaleString()}</span>`;
|
|
}
|
|
|
|
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 || '<span style="font-size: 0.65rem; color: #6b7280;">No active stash</span>';
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
});
|