822 lines
30 KiB
JavaScript
822 lines
30 KiB
JavaScript
// EL PURO - SilkNet OS Core Engine
|
|
|
|
const INITIAL_GAME_STATE = {
|
|
cash: 25.00,
|
|
cleanBonds: 0,
|
|
heat: 0,
|
|
totalEarned: 0,
|
|
activeTab: 'lab',
|
|
drugs: {},
|
|
skills: {},
|
|
achievements: {},
|
|
synthesis: {},
|
|
lastTick: Date.now()
|
|
};
|
|
|
|
// Initialize drugs state
|
|
DRUG_CATALOG.forEach(d => {
|
|
INITIAL_GAME_STATE.drugs[d.id] = {
|
|
unlocked: d.unlockedDefault,
|
|
raw: 0,
|
|
product: 0,
|
|
workers: 0,
|
|
dealers: 0
|
|
};
|
|
});
|
|
|
|
SKILL_TREE.forEach(s => {
|
|
INITIAL_GAME_STATE.skills[s.id] = false;
|
|
});
|
|
|
|
ACHIEVEMENTS.forEach(a => {
|
|
INITIAL_GAME_STATE.achievements[a.id] = false;
|
|
});
|
|
|
|
SYNTHESIS_RECIPES.forEach(r => {
|
|
INITIAL_GAME_STATE.synthesis[r.id] = 0;
|
|
});
|
|
|
|
let G = JSON.parse(JSON.stringify(INITIAL_GAME_STATE));
|
|
let cashRateTracker = 0;
|
|
let lastSaveTime = Date.now();
|
|
let lastRaidCheck = Date.now();
|
|
|
|
// --- 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')
|
|
};
|
|
|
|
// --- MULTIPLIERS & BONUSES ---
|
|
function getProductionMultiplier() {
|
|
let m = 1.0;
|
|
// Clean bonds: +3% production each
|
|
m += G.cleanBonds * 0.03;
|
|
if (G.skills['chem_1']) m += 0.15;
|
|
if (G.achievements['millionaire']) m += 0.20;
|
|
return m;
|
|
}
|
|
|
|
function getSellPriceMultiplier() {
|
|
let m = 1.0;
|
|
// Clean bonds: +4% sell price each
|
|
m += G.cleanBonds * 0.04;
|
|
if (G.skills['king_2']) m += 0.25;
|
|
if (G.achievements['first_hit']) m += 0.05;
|
|
if (G.achievements['cartel_tier']) m += 0.15;
|
|
return m;
|
|
}
|
|
|
|
function getRawCostMultiplier() {
|
|
let m = 1.0;
|
|
if (G.skills['chem_2']) m *= 0.85;
|
|
return m;
|
|
}
|
|
|
|
function getDealerSpeedMultiplier() {
|
|
let m = 1.0;
|
|
if (G.skills['king_1']) m += 0.20;
|
|
if (G.skills['king_3']) m += 0.50;
|
|
return m;
|
|
}
|
|
|
|
// Cost scaling formulas
|
|
function getWorkerCost(drugId) {
|
|
const config = DRUG_CATALOG.find(d => d.id === drugId);
|
|
const count = G.drugs[drugId].workers;
|
|
return Math.floor(config.workerCost * Math.pow(1.15, count));
|
|
}
|
|
|
|
function getDealerCost(drugId) {
|
|
const config = DRUG_CATALOG.find(d => d.id === drugId);
|
|
const count = G.drugs[drugId].dealers;
|
|
return Math.floor(config.dealerCost * Math.pow(1.15, count));
|
|
}
|
|
|
|
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 === '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];
|
|
const rawCost = Math.floor(d.rawCost * getRawCostMultiplier());
|
|
const workerCost = getWorkerCost(d.id);
|
|
|
|
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 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;
|
|
}
|
|
|
|
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);">${d.workerName}s: ${data.workers}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-stats-row">
|
|
<div class="stat-item">
|
|
<span class="stat-label">${d.rawName}</span>
|
|
<span class="stat-value" id="val-raw-${d.id}">${data.raw}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">${d.productName}</span>
|
|
<span class="stat-value" id="val-prod-${d.id}">${data.product}g</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-grid-actions">
|
|
<button class="btn-tactical" onclick="buyRaw('${d.id}')" ${G.cash < rawCost ? 'disabled' : ''}>
|
|
Buy Raw
|
|
<span class="btn-subtext">$${rawCost.toLocaleString()}</span>
|
|
</button>
|
|
<button 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 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 class="btn-tactical" onclick="hireWorker('${d.id}')" ${G.cash < workerCost ? 'disabled' : ''}>
|
|
Hire ${d.workerName}
|
|
<span class="btn-subtext">$${workerCost.toLocaleString()}</span>
|
|
</button>
|
|
<button class="btn-tactical" style="opacity: 0.8; font-size: 0.65rem;" onclick="setTab('streets')">
|
|
Manage Dealers ->
|
|
</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].unlocked).forEach(d => {
|
|
const data = G.drugs[d.id];
|
|
const dealerCost = getDealerCost(d.id);
|
|
const unitPrice = Math.floor(d.sellPrice * getSellPriceMultiplier());
|
|
|
|
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);">${d.dealerName}s: ${data.dealers}</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">${data.product}g</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Deal Speed</span>
|
|
<span class="stat-value">${(data.dealers * d.sellRate * getDealerSpeedMultiplier()).toFixed(1)}/s</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-grid-auto">
|
|
<button class="btn-tactical btn-sell-action" onclick="hireDealer('${d.id}')" ${G.cash < dealerCost ? 'disabled' : ''}>
|
|
Hire ${d.dealerName}
|
|
<span class="btn-subtext">$${dealerCost.toLocaleString()}</span>
|
|
</button>
|
|
<button class="btn-tactical" onclick="sellManual('${d.id}', 10)" ${data.product < 10 ? 'disabled' : ''}>
|
|
Sell 10x Stash
|
|
<span class="btn-subtext">+$${(unitPrice * Math.min(10, 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} (${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;
|
|
}
|
|
|
|
// 4. OFFSHORE (PRESTIGE & MONEY LAUNDERING)
|
|
function renderOffshoreView() {
|
|
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
|
|
const nextBonds = Math.max(0, bondsToEarn - G.cleanBonds);
|
|
|
|
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: 20px 14px;">
|
|
<iconify-icon icon="game-icons:tied-scroll" style="font-size: 3rem; color: var(--neon-cyan);"></iconify-icon>
|
|
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 8px;">Launder Dirty Syndicate Cash</div>
|
|
<p style="font-size: 0.78rem; color: var(--text-secondary); margin: 8px 0 14px; line-height: 1.4;">
|
|
Reset dirty cash, lab equipment, and street dealers in exchange for permanent <b>Clean Offshore Bonds</b>.
|
|
Each Bond permanently boosts production (+3%) and sale values (+4%).
|
|
</p>
|
|
<div style="background: #090e13; border: 1px solid #1f2a36; border-radius: 8px; padding: 12px; margin-bottom: 14px; font-family: var(--font-mono);">
|
|
<div style="font-size: 0.7rem; color: var(--text-secondary);">CLEAN BONDS 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);">Career Volume: $${Math.floor(G.totalEarned).toLocaleString()}</div>
|
|
</div>
|
|
<button class="btn-tactical btn-sell-action" style="padding: 12px; font-size: 0.9rem;" 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.72rem; color: var(--text-secondary); margin: 16px 0 6px;">
|
|
GLOBAL SYNDICATE ACHIEVEMENTS
|
|
</div>
|
|
`;
|
|
|
|
ACHIEVEMENTS.forEach(a => {
|
|
const has = G.achievements[a.id];
|
|
html += `
|
|
<div class="card" style="display: flex; justify-content: space-between; align-items: center; opacity: ${has ? '1' : '0.5'};">
|
|
<div>
|
|
<div style="font-weight: bold; font-size: 0.8rem; color: ${has ? '#eab308' : '#8b949e'};">
|
|
${has ? '🏆 ' : '🔒 '}${a.name}
|
|
</div>
|
|
<div style="font-size: 0.68rem; color: var(--text-secondary);">${a.desc}</div>
|
|
</div>
|
|
<div style="font-family: var(--font-mono); font-size: 0.68rem; color: var(--neon-green); text-align: right;">
|
|
${a.reward}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
dom.viewport.innerHTML = html;
|
|
}
|
|
|
|
// --- CORE ACTIONS ---
|
|
function buyRaw(drugId) {
|
|
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
|
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
|
|
if (G.cash >= cost) {
|
|
G.cash -= cost;
|
|
G.drugs[drugId].raw += 1;
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function manufacture(drugId) {
|
|
const data = G.drugs[drugId];
|
|
if (data.raw >= 1) {
|
|
data.raw -= 1;
|
|
let yieldCount = 1;
|
|
// 99.1% crystallization perk
|
|
if (G.skills['chem_3'] && Math.random() < 0.10) {
|
|
yieldCount = 3;
|
|
showToast('99.1% Purity Proc! 3x Yield!', false);
|
|
}
|
|
data.product += yieldCount;
|
|
addHeat(0.5);
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function sellManual(drugId, amount = 1) {
|
|
const d = DRUG_CATALOG.find(x => x.id === drugId);
|
|
const data = G.drugs[drugId];
|
|
const actual = Math.min(amount, data.product);
|
|
if (actual > 0) {
|
|
data.product -= actual;
|
|
const profit = actual * Math.floor(d.sellPrice * getSellPriceMultiplier());
|
|
G.cash += profit;
|
|
G.totalEarned += profit;
|
|
addHeat(actual * 0.8);
|
|
checkAchievements();
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function hireWorker(drugId) {
|
|
const cost = getWorkerCost(drugId);
|
|
if (G.cash >= cost) {
|
|
G.cash -= cost;
|
|
G.drugs[drugId].workers += 1;
|
|
checkAchievements();
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function hireDealer(drugId) {
|
|
const cost = getDealerCost(drugId);
|
|
if (G.cash >= cost) {
|
|
G.cash -= cost;
|
|
G.drugs[drugId].dealers += 1;
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function unlockDrug(drugId) {
|
|
const cost = getUnlockCost(drugId);
|
|
if (G.cash >= cost && !G.drugs[drugId].unlocked) {
|
|
G.cash -= cost;
|
|
G.drugs[drugId].unlocked = true;
|
|
showToast(`Unlocked formula for ${DRUG_CATALOG.find(d => d.id === drugId).name}!`);
|
|
checkAchievements();
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
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);
|
|
showToast(`Synthesized & Sold ${r.name} for +$${r.sellPrice.toLocaleString()}!`);
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
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;
|
|
showToast(`Skill Activated: ${s.name}`);
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
function launderMoney() {
|
|
const bondsToEarn = Math.floor(Math.sqrt(Math.max(0, G.totalEarned) / 25000));
|
|
const gain = Math.max(0, bondsToEarn - G.cleanBonds);
|
|
if (gain < 1) return;
|
|
|
|
if (confirm(`Launder all assets for +${gain} Clean Offshore Bonds? Your syndicate operations will reset.`)) {
|
|
G.cleanBonds += gain;
|
|
G.cash = 50.00;
|
|
G.heat = 0;
|
|
DRUG_CATALOG.forEach(d => {
|
|
G.drugs[d.id] = {
|
|
unlocked: d.unlockedDefault,
|
|
raw: 0,
|
|
product: 0,
|
|
workers: 0,
|
|
dealers: 0
|
|
};
|
|
});
|
|
checkAchievements();
|
|
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
|
|
updateHud();
|
|
renderViewport();
|
|
}
|
|
}
|
|
|
|
// --- POLICE HEAT & RAIDS ---
|
|
function addHeat(amount) {
|
|
let riseMod = G.skills['smug_1'] ? 0.65 : 1.0;
|
|
G.heat = Math.min(100, 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;
|
|
dom.raidModal.classList.remove('active');
|
|
showToast('Sheriff bribed. Heat cleared.');
|
|
updateHud();
|
|
} else {
|
|
showToast('Insufficient cash for bribe!', true);
|
|
}
|
|
};
|
|
|
|
document.getElementById('btn-raid-stash').onclick = () => {
|
|
G.heat = 20;
|
|
dom.raidModal.classList.remove('active');
|
|
showToast('Stash flushed & secured. Heat diminished.');
|
|
updateHud();
|
|
};
|
|
|
|
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;
|
|
showToast(`Broke through blockade! Seized +$${reward.toLocaleString()}!`);
|
|
} else {
|
|
// Seize 30% of inventory
|
|
DRUG_CATALOG.forEach(d => {
|
|
G.drugs[d.id].product = Math.floor(G.drugs[d.id].product * 0.7);
|
|
});
|
|
G.heat = 30;
|
|
showToast('Agents seized 30% of stored product!', true);
|
|
}
|
|
updateHud();
|
|
renderViewport();
|
|
};
|
|
}
|
|
|
|
// --- ACHIEVEMENTS CHECK ---
|
|
function checkAchievements() {
|
|
if (G.totalEarned > 0 && !G.achievements['first_hit']) {
|
|
G.achievements['first_hit'] = true;
|
|
showToast('Achievement: First Batch!');
|
|
}
|
|
if (G.drugs['weed'].workers >= 10 && !G.achievements['grower_op']) {
|
|
G.achievements['grower_op'] = true;
|
|
showToast('Achievement: Green Thumb!');
|
|
}
|
|
if (G.drugs['meth'].unlocked && !G.achievements['blue_sky']) {
|
|
G.achievements['blue_sky'] = true;
|
|
showToast('Achievement: Say My Name!');
|
|
}
|
|
if (G.drugs['cocaine'].unlocked && !G.achievements['cartel_tier']) {
|
|
G.achievements['cartel_tier'] = true;
|
|
showToast('Achievement: El Patrón!');
|
|
}
|
|
if (G.cash >= 1000000 && !G.achievements['millionaire']) {
|
|
G.achievements['millionaire'] = true;
|
|
showToast('Achievement: Seven Figures!');
|
|
}
|
|
if (G.cleanBonds >= 1 && !G.achievements['laundered']) {
|
|
G.achievements['laundered'] = true;
|
|
showToast('Achievement: Clean Hands!');
|
|
}
|
|
}
|
|
|
|
// --- TICK & GAME LOOP ---
|
|
function tick(deltaSec) {
|
|
let earnedThisTick = 0;
|
|
const prodMulti = getProductionMultiplier();
|
|
const sellMulti = getSellPriceMultiplier();
|
|
const dealerSpeed = getDealerSpeedMultiplier();
|
|
|
|
DRUG_CATALOG.forEach(d => {
|
|
const data = G.drugs[d.id];
|
|
if (!data.unlocked) return;
|
|
|
|
// Auto Production
|
|
if (data.workers > 0 && data.raw > 0) {
|
|
const prodPerSec = data.workers * d.produceRate * prodMulti;
|
|
const toMake = Math.min(data.raw, prodPerSec * deltaSec);
|
|
data.raw -= toMake;
|
|
data.product += toMake;
|
|
}
|
|
|
|
// Auto Sales
|
|
if (data.dealers > 0 && data.product > 0) {
|
|
const sellPerSec = data.dealers * d.sellRate * dealerSpeed;
|
|
const toSell = Math.min(data.product, sellPerSec * deltaSec);
|
|
data.product -= toSell;
|
|
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
|
|
G.cash += revenue;
|
|
G.totalEarned += revenue;
|
|
earnedThisTick += revenue;
|
|
}
|
|
});
|
|
|
|
// Heat decay
|
|
if (G.heat > 0) {
|
|
G.heat = Math.max(0, G.heat - (0.5 * deltaSec));
|
|
}
|
|
|
|
if (deltaSec > 0) {
|
|
cashRateTracker = cashRateTracker * 0.85 + (earnedThisTick / deltaSec) * 0.15;
|
|
}
|
|
|
|
// Periodic raid check
|
|
if (Date.now() - lastRaidCheck > 12000) {
|
|
lastRaidCheck = Date.now();
|
|
checkRaidEvent();
|
|
}
|
|
|
|
updateHud();
|
|
}
|
|
|
|
function updateHud() {
|
|
dom.cash.textContent = G.cash.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
dom.rate.textContent = cashRateTracker.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
dom.bonds.textContent = G.cleanBonds;
|
|
dom.multiplier.textContent = Math.round((G.cleanBonds * 4));
|
|
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
|
|
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
|
|
}
|
|
|
|
// Game loop with requestAnimationFrame
|
|
let lastFrameTime = performance.now();
|
|
function gameLoop(now) {
|
|
const delta = (now - lastFrameTime) / 1000;
|
|
lastFrameTime = now;
|
|
if (delta > 0 && delta < 5) {
|
|
tick(delta);
|
|
}
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// Clock updates
|
|
setInterval(() => {
|
|
const d = new Date();
|
|
dom.clock.textContent = d.toTimeString().substring(0, 5);
|
|
}, 1000);
|
|
|
|
// Auto-save every 5 seconds (Local & Cloud)
|
|
setInterval(async () => {
|
|
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
|
|
if (API.getToken()) {
|
|
await API.saveRemote(G);
|
|
}
|
|
}, 5000);
|
|
|
|
// --- INITIALIZATION & EVENTS ---
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Check auth status
|
|
const username = API.getUsername();
|
|
if (username) {
|
|
dom.userBadge.textContent = `[${username}]`;
|
|
}
|
|
|
|
// Load Save: Try cloud first if logged in, fallback to local
|
|
let loaded = false;
|
|
if (API.getToken()) {
|
|
const remote = await API.loadRemoteSave();
|
|
if (remote) {
|
|
G = { ...INITIAL_GAME_STATE, ...remote };
|
|
loaded = true;
|
|
}
|
|
}
|
|
if (!loaded) {
|
|
const local = localStorage.getItem('el_puro_local_save');
|
|
if (local) {
|
|
try {
|
|
G = { ...INITIAL_GAME_STATE, ...JSON.parse(local) };
|
|
} catch (e) {
|
|
console.warn('Corrupt local save, initializing new game');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setup navigation tabs
|
|
document.querySelectorAll('.nav-tab').forEach(b => {
|
|
b.addEventListener('click', () => setTab(b.dataset.tab));
|
|
});
|
|
|
|
// 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}`);
|
|
// Sync game save from cloud
|
|
const remote = await API.loadRemoteSave();
|
|
if (remote) {
|
|
G = { ...INITIAL_GAME_STATE, ...remote };
|
|
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; // Honeypot field!
|
|
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}`);
|
|
await API.saveRemote(G);
|
|
} catch (e) {
|
|
showToast(e.message, true);
|
|
}
|
|
});
|
|
|
|
renderViewport();
|
|
updateHud();
|
|
requestAnimationFrame(gameLoop);
|
|
});
|