ProductionGame/frontend/js/game.js

884 lines
34 KiB
JavaScript

// EL PURO - SilkNet OS Core Engine (Authoritative Server Synchronization)
const INITIAL_GAME_STATE = {
cash: 25.0,
cleanBonds: 0,
heat: 0.0,
totalEarned: 0.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.0,
product: 0.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.0;
let pendingActions = []; // Actions queued between 6-second authoritative server ticks
let isSyncing = false;
// --- NUMBER FORMATTING HELPER (Max 1 decimal, graceful fallback) ---
function fmtNum(val) {
if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0';
const rounded = Math.round(Number(val) * 10) / 10;
// If it's an integer, show with .0 for uniform currency or clean integer for items
return rounded.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
function fmtInt(val) {
if (val === undefined || val === null || isNaN(val) || !isFinite(val)) return '0';
return Math.floor(Math.max(0, Number(val))).toLocaleString();
}
// --- 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;
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;
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] || { raw: 0, product: 0, workers: 0, dealers: 0, unlocked: false };
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}">${fmtNum(data.raw)}</span>
</div>
<div class="stat-item">
<span class="stat-label">${d.productName}</span>
<span class="stat-value" id="val-prod-${d.id}">${fmtNum(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] && 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" 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(data.dealers * d.sellRate * getDealerSpeedMultiplier())}/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, 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;
}
// 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: $${fmtNum(G.totalEarned)}</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;
}
// --- OPTIMISTIC CLIENT ACTIONS (Queued for Server Authority) ---
function queueAction(action) {
pendingActions.push(action);
}
function buyRaw(drugId) {
const d = DRUG_CATALOG.find(x => x.id === drugId);
const cost = Math.floor(d.rawCost * getRawCostMultiplier());
if (G.cash >= cost) {
// Optimistic local UI update
G.cash -= cost;
G.drugs[drugId].raw += 1;
queueAction({ type: 'buy_raw', drug_id: drugId, amount: 1 });
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);
}
data.product += yieldCount;
addHeat(0.5);
queueAction({ type: 'manufacture', drug_id: drugId, amount: 1 });
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;
addHeat(actual * 0.8);
queueAction({ type: 'sell_manual', drug_id: drugId, amount: actual });
checkAchievements();
updateLiveInventory();
}
}
function hireWorker(drugId) {
const cost = getWorkerCost(drugId);
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].workers += 1;
queueAction({ type: 'hire_worker', drug_id: drugId });
checkAchievements();
renderViewport();
updateLiveInventory();
}
}
function hireDealer(drugId) {
const cost = getDealerCost(drugId);
if (G.cash >= cost) {
G.cash -= cost;
G.drugs[drugId].dealers += 1;
queueAction({ type: 'hire_dealer', drug_id: drugId });
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;
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();
}
}
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.`)) {
queueAction({ type: 'launder_prestige' });
G.cleanBonds += gain;
G.cash = 50.0;
G.heat = 0.0;
DRUG_CATALOG.forEach(d => {
G.drugs[d.id] = {
unlocked: d.unlockedDefault,
raw: 0.0,
product: 0.0,
workers: 0,
dealers: 0
};
});
checkAchievements();
showToast(`Laundered! You now have ${G.cleanBonds} Clean Bonds.`);
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 ---
function checkAchievements() {
if (G.totalEarned > 0 && !G.achievements['first_hit']) {
G.achievements['first_hit'] = true;
showToast('Achievement: First Batch!');
}
if (G.drugs['weed'] && G.drugs['weed'].workers >= 10 && !G.achievements['grower_op']) {
G.achievements['grower_op'] = true;
showToast('Achievement: Green Thumb!');
}
if (G.drugs['meth'] && G.drugs['meth'].unlocked && !G.achievements['blue_sky']) {
G.achievements['blue_sky'] = true;
showToast('Achievement: Say My Name!');
}
if (G.drugs['cocaine'] && 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!');
}
}
// --- 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();
DRUG_CATALOG.forEach(d => {
const data = G.drugs[d.id];
if (!data || !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 = Math.max(0, data.raw - toMake);
data.product = 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 = Math.max(0, data.product - toSell);
const revenue = toSell * Math.floor(d.sellPrice * sellMulti);
G.cash += revenue;
G.totalEarned += revenue;
earnedThisTick += revenue;
}
});
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, surgical DOM text updates every tick (no lag, no re-rendering entire card HTML)
updateLiveInventory();
}
function updateLiveInventory() {
dom.cash.textContent = fmtNum(G.cash);
dom.rate.textContent = fmtNum(cashRateTracker);
dom.bonds.textContent = fmtInt(G.cleanBonds);
dom.multiplier.textContent = fmtInt(G.cleanBonds * 4);
dom.heatPct.textContent = `${Math.round(G.heat)}%`;
dom.heatBar.style.width = `${Math.min(100, Math.round(G.heat))}%`;
// Surgically update raw and product text fields if they exist in DOM
DRUG_CATALOG.forEach(d => {
const rawEl = document.getElementById(`val-raw-${d.id}`);
if (rawEl && G.drugs[d.id]) {
rawEl.textContent = fmtNum(G.drugs[d.id].raw);
}
const prodEl = document.getElementById(`val-prod-${d.id}`);
if (prodEl && G.drugs[d.id]) {
prodEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
const streetProdEl = document.getElementById(`val-prod-street-${d.id}`);
if (streetProdEl && G.drugs[d.id]) {
streetProdEl.textContent = `${fmtNum(G.drugs[d.id].product)}g`;
}
});
}
// --- AUTHORITATIVE SERVER TICK (Every 6 seconds) ---
async function authoritativeServerTick() {
if (isSyncing) return;
isSyncing = true;
// Grab queued actions and reset local queue
const actionsToSend = [...pendingActions];
pendingActions = [];
if (API.getToken()) {
const serverState = await API.syncTick(actionsToSend);
if (serverState) {
// Reconcile authoritative server state (protects against browser devtools tampering)
G = {
...INITIAL_GAME_STATE,
...serverState,
activeTab: G.activeTab // Preserve UI view tab
};
checkAchievements();
updateLiveInventory();
}
} else {
// Guest mode / offline fallback: persist locally
localStorage.setItem('el_puro_local_save', JSON.stringify(G));
}
isSyncing = false;
}
// 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);
}
// Server tick: exactly every 6 seconds to prevent flooding server
setInterval(authoritativeServerTick, 6000);
// 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));
});
// 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);
}
});
renderViewport();
updateLiveInventory();
requestAnimationFrame(gameLoop);
});