ProductionGame/frontend/js/app.js

752 lines
31 KiB
JavaScript

// Production - The Idle Game: Main Application Orchestrator
// Fine-grained DOM updates without full innerHTML teardowns
import { GState } from './core/state.js';
import { SimulationEngine } from './core/engine.js';
import { SyncManager } from './core/sync.js';
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
import { PRODUCTS, SECTORS, getProduct } from './data/products.js';
import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.js';
import { renderSectorTabsHtml } from './ui/production/sectorBar.js';
import { mountUnitDetailView, updateUnitDetailView } from './ui/unitDetailView.js';
import { mountContractsBoard, updateContractsBoard } from './ui/contractsView.js';
import { mountBoardroom, updateBoardroom } from './ui/boardroomView.js';
import { mountInspector, updateInspector } from './ui/inspectorView.js';
import { mountAchievements, updateAchievements } from './ui/achievementsView.js';
class ProductionApp {
constructor() {
this.sm = GState;
this.engine = new SimulationEngine(this.sm);
this.sync = new SyncManager(this.sm);
this.lastFrameTime = performance.now();
this.lastSaveTime = Date.now();
this.currentMountedTab = null;
this.currentMountedSector = null;
this.currentMountedProduct = null;
}
async init() {
// Check for offline progress
const now = Date.now();
const elapsedSeconds = (now - this.sm.state.lastTick) / 1000;
if (elapsedSeconds > 5) {
const result = this.engine.processOffline(elapsedSeconds);
if (result.earned > 0) {
this.showToast(`Executive Briefing: Your automated lines generated ${fmtCurrency(result.earned)} during ${Math.round(result.seconds / 60)}m away!`, 'success');
}
}
this.setupDomEvents();
// Initial Mount (builds DOM nodes ONCE)
this.mountActiveView();
this.mountInspectorPane();
this.updateHud();
// Check if logged in and sync cloud state
if (this.sync.isLoggedIn()) {
this.sync.fetchCloudState().then(cloud => {
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
this.sm.state = { ...this.sm.state, ...cloud };
this.sm.save();
this.mountActiveView();
this.mountInspectorPane();
}
});
}
// Main game loop (ticks at 60fps, fine-grained updates without innerHTML)
const loop = (currentTime) => {
const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5);
this.lastFrameTime = currentTime;
this.engine.tick(dt);
this.updateHud();
this.updateActiveView();
this.updateInspectorPane();
// Auto-save every 5 seconds
if (Date.now() - this.lastSaveTime > 5000) {
this.sm.save();
this.lastSaveTime = Date.now();
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
setupDomEvents() {
// Navigation Tabs (Desktop sidebar + Mobile bottom nav)
document.querySelectorAll('.nav-btn, .nav-tab').forEach(btn => {
btn.onclick = () => {
const tab = btn.dataset.tab;
if (!tab) return;
this.sm.state.activeTab = tab;
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
this.mountActiveView();
};
});
// Buy Multiplier Bar
document.querySelectorAll('.btn-multi-pill').forEach(btn => {
btn.onclick = () => {
const mode = btn.dataset.mode;
this.sm.state.buyMode = mode;
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.mountActiveView(); // Remount button labels for 1x/10x/100x
};
});
// Sidebar Sector Buttons
document.querySelectorAll('.sidebar-sector-btn').forEach(btn => {
btn.onclick = () => {
const sector = btn.dataset.sector;
this.sm.state.activeTab = 'production';
this.sm.state.selectedSector = sector;
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === 'production');
});
this.mountActiveView();
};
});
// User Profile & Settings
const userBadge = document.getElementById('user-badge');
if (userBadge) {
userBadge.onclick = () => this.openAuthModal();
}
const settingsBtn = document.getElementById('btn-open-settings');
if (settingsBtn) {
settingsBtn.onclick = () => this.toggleSettings();
}
// Dark / Light Theme Toggle
this.initTheme();
const themeBtn = document.getElementById('btn-theme-toggle');
if (themeBtn) {
themeBtn.onclick = () => this.toggleTheme();
}
// Global Keyboard Shortcut: ESC returns to production floor if on unit_detail
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.sm.state.activeTab === 'unit_detail') {
this.handleAction({ type: 'CLOSE_UNIT_DETAIL' });
}
});
// Auth Modal Close & Submit
const closeAuth = document.getElementById('btn-close-auth');
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
const loginBtn = document.getElementById('btn-login-submit');
if (loginBtn) loginBtn.onclick = () => this.handleLogin();
const registerBtn = document.getElementById('btn-register-submit');
if (registerBtn) registerBtn.onclick = () => this.handleRegister();
}
initTheme() {
const savedTheme = localStorage.getItem('production_theme') ||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
this.applyTheme(savedTheme);
}
toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'light';
const next = current === 'dark' ? 'light' : 'dark';
this.applyTheme(next);
this.showToast(`Theme switched to ${next.toUpperCase()} mode`, 'info');
}
applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('production_theme', theme);
const iconEl = document.getElementById('theme-toggle-icon');
const textEl = document.getElementById('theme-toggle-text');
if (iconEl) {
iconEl.setAttribute('icon', theme === 'dark' ? 'lucide:sun' : 'lucide:moon');
}
if (textEl) {
textEl.textContent = theme === 'dark' ? 'Light' : 'Dark';
}
}
handleAction(action) {
const state = this.sm.state;
switch (action.type) {
case 'SELECT_SECTOR':
state.selectedSector = action.sector;
if (state.activeTab !== 'production') {
state.activeTab = 'production';
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === 'production');
});
}
this.mountActiveView();
break;
case 'OPEN_UNIT_DETAIL': {
state.selectedProductId = action.productId;
state.activeTab = 'unit_detail';
this.mountActiveView();
window.scrollTo({ top: 0, behavior: 'smooth' });
break;
}
case 'CLOSE_UNIT_DETAIL': {
state.activeTab = 'production';
this.mountActiveView();
requestAnimationFrame(() => {
const cardEl = document.getElementById('card-' + state.selectedProductId);
if (cardEl) {
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
break;
}
case 'SELECT_PRODUCT': {
state.selectedProductId = action.productId;
const targetP = getProduct(action.productId);
// If user selected a dependency or unit from inspector, ensure we navigate / go to that facility card
let needsViewMount = false;
if (state.activeTab !== 'production') {
state.activeTab = 'production';
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === 'production');
});
needsViewMount = true;
}
if (targetP && state.selectedSector !== 'all' && state.selectedSector !== targetP.sector) {
state.selectedSector = targetP.sector;
needsViewMount = true;
}
if (needsViewMount) {
this.mountActiveView();
}
this.mountInspectorPane();
// Smooth scroll to the facility card and highlight it
requestAnimationFrame(() => {
const cardEl = document.getElementById('card-' + action.productId);
if (cardEl) {
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
cardEl.classList.remove('card-highlight-pulse');
void cardEl.offsetWidth; // Force CSS reflow to re-trigger animation
cardEl.classList.add('card-highlight-pulse');
setTimeout(() => {
cardEl.classList.remove('card-highlight-pulse');
}, 1600);
}
});
break;
}
case 'UNLOCK_PRODUCT': {
const p = getProduct(action.productId);
if (p && state.cash >= p.unlockCost && !state.products[p.id]?.unlocked) {
state.cash -= p.unlockCost;
if (!state.products[p.id]) state.products[p.id] = {};
state.products[p.id].unlocked = true;
state.products[p.id].lines = 1;
state.selectedProductId = p.id;
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
this.sync.queueAction(action);
this.mountActiveView();
this.mountInspectorPane();
}
break;
}
case 'BUY_LINES': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, state.buyMode);
if (state.cash >= calc.cost) {
state.cash -= calc.cost;
pState.lines += calc.count;
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
this.sync.queueAction(action);
this.mountActiveView();
this.mountInspectorPane();
}
}
break;
}
case 'HIRE_MANAGER': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState && !pState.manager && state.cash >= p.baseManagerCost) {
state.cash -= p.baseManagerCost;
pState.manager = true;
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
this.sync.queueAction(action);
this.mountActiveView();
}
break;
}
case 'BUY_LOGISTICS': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const cost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
if (state.cash >= cost) {
state.cash -= cost;
pState.logistics = (pState.logistics || 0) + 1;
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
this.sync.queueAction(action);
this.mountActiveView();
this.mountInspectorPane();
}
}
break;
}
case 'TOGGLE_LOGISTICS': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const nextVal = pState.logisticsEnabled === false ? true : false;
pState.logisticsEnabled = nextVal;
this.showToast(`${p.name} Wholesale Fleet: ${nextVal ? 'ENABLED' : 'PAUSED'}`, nextVal ? 'success' : 'info');
this.sync.queueAction(action);
this.updateActiveView();
this.updateInspectorPane();
}
break;
}
case 'TOGGLE_DOWNSTREAM_SUPPLY': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const nextVal = pState.supplyDownstreamEnabled === false ? true : false;
pState.supplyDownstreamEnabled = nextVal;
this.showToast(`${p.name} Downstream Supply: ${nextVal ? 'ALLOWED' : 'BLOCKED'}`, nextVal ? 'success' : 'warning');
this.sync.queueAction(action);
this.updateActiveView();
this.updateInspectorPane();
}
break;
}
case 'SET_PRODUCTION_TARGET': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const target = Math.max(0, Math.min(1.0, action.target));
pState.productionTarget = target;
const pct = Math.round(target * 100);
this.showToast(`${p.name} Production Target set to ${pct}%`, 'info');
this.sync.queueAction(action);
this.updateActiveView();
this.updateInspectorPane();
}
break;
}
case 'MANUAL_CYCLE': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState) {
const isRaw = !p.inputs || p.inputs.length === 0;
let canRun = true;
if (!isRaw) {
canRun = p.inputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount);
}
if (canRun) {
if (!isRaw) {
p.inputs.forEach(inp => {
state.products[inp.id].stock -= inp.amount;
});
}
const yieldAmt = p.outputAmount * Math.max(1, pState.lines);
pState.stock += yieldAmt;
pState.totalProduced = (pState.totalProduced || 0) + yieldAmt;
state.stats.totalProduced = (state.stats.totalProduced || 0) + yieldAmt;
this.showToast(`Batch Produced: +${yieldAmt} ${p.name}!`, 'info');
} else {
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
}
}
break;
}
case 'FULFILL_CONTRACT': {
const idx = (state.activeContracts || []).findIndex(c => c.uid === action.uid);
if (idx !== -1) {
const c = state.activeContracts[idx];
const pState = state.products[c.productId];
if (pState && pState.stock >= c.targetAmount) {
pState.stock -= c.targetAmount;
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
const totalPayout = c.rewardCash * subsidyBonus;
state.cash += totalPayout;
state.lifetimeRevenue += totalPayout;
state.reputation = (state.reputation || 0) + c.rewardRep;
state.stats.contractsCompleted = (state.stats.contractsCompleted || 0) + 1;
state.activeContracts.splice(idx, 1);
state.contractCooldown = 10.0; // 10 second cooldown before new tender RFP appears
// Recompute readyContractsCount
let readyCount = 0;
for (const actC of state.activeContracts) {
const ps = state.products[actC.productId];
if (ps && (ps.stock || 0) >= actC.targetAmount) readyCount++;
}
state.readyContractsCount = readyCount;
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
this.sync.queueAction(action);
this.mountActiveView();
}
}
break;
}
case 'TRIGGER_IPO': {
this.showConfirmModal({
title: 'Authorize Initial Public Offering (IPO)',
subtitle: 'Corporate Equity Transition',
icon: 'lucide:trending-up',
text: 'Confirm Corporate Initial Public Offering (IPO)?<br><br>All factory floors and cash will be liquidated in exchange for permanent <strong>Venture Shares ($V-Shares)</strong> and corporate prestige bonuses!',
okText: 'Execute IPO',
cancelText: 'Abort'
}).then(confirmed => {
if (confirmed) {
const success = this.sm.performIPO();
if (success) {
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
this.mountActiveView();
this.mountInspectorPane();
}
}
});
break;
}
case 'BUY_PERK': {
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
if (def && perk < def.maxLevel) {
const cost = def.costShares[perk];
if (state.ventureShares >= cost) {
state.ventureShares -= cost;
state.boardroomPerks[action.perkId] = perk + 1;
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
this.mountActiveView();
}
}
});
break;
}
case 'LIQUIDATE_STOCK': {
const p = getProduct(action.productId);
const pState = state.products[action.productId];
if (p && pState && pState.stock > 0) {
const ratio = action.ratio || 1.0;
const sold = Math.floor(pState.stock * ratio);
if (sold > 0) {
pState.stock -= sold;
const earned = sold * p.basePrice;
state.cash += earned;
state.lifetimeRevenue += earned;
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
}
}
break;
}
}
}
updateHud() {
const state = this.sm.state;
const elCash = document.getElementById('hud-cash');
if (elCash) {
const val = fmtNum(state.cash);
if (elCash.textContent !== val) elCash.textContent = val;
}
const elRate = document.getElementById('hud-rate');
if (elRate) {
const val = fmtRate(this.engine.cashRate).replace('+$', '');
if (elRate.textContent !== val) elRate.textContent = val;
}
const elValuation = document.getElementById('hud-valuation');
if (elValuation) {
const val = fmtCurrency(state.valuation);
if (elValuation.textContent !== val) elValuation.textContent = val;
}
const elShares = document.getElementById('hud-shares');
if (elShares) {
const val = fmtNum(state.ventureShares, 0);
if (elShares.textContent !== val) elShares.textContent = val;
}
const elContractsBadge = document.getElementById('contracts-badge');
if (elContractsBadge) {
const readyCount = state.readyContractsCount || 0;
const textVal = String(readyCount);
if (elContractsBadge.textContent !== textVal) {
elContractsBadge.textContent = textVal;
}
elContractsBadge.style.display = readyCount > 0 ? 'inline-flex' : 'none';
}
const userBadge = document.getElementById('user-badge');
if (userBadge) {
userBadge.textContent = this.sync.isLoggedIn() ? `[${this.sync.getUsername()}]` : `[GUEST]`;
}
// Update sidebar sector rate badges
if (this.engine.sectorStats) {
for (const s of SECTORS) {
const stats = this.engine.sectorStats[s.id];
if (stats) {
const elRate = document.getElementById(`sb-rate-${s.id}`);
if (elRate) {
const val = `+$${fmtNum(stats.revenueRate, 2)}/s`;
if (elRate.textContent !== val) elRate.textContent = val;
}
}
}
}
}
mountHeaderSectorBar() {
const headerSectorContainer = document.getElementById('header-sector-container');
if (!headerSectorContainer) return;
const selectedSector = this.sm.state.selectedSector || 'all';
headerSectorContainer.innerHTML = renderSectorTabsHtml(selectedSector, this.engine);
headerSectorContainer.querySelectorAll('.sector-pill').forEach(btn => {
btn.onclick = () => this.handleAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
});
}
/**
* Mounts the main viewport view ONCE when the tab or sector changes
*/
mountActiveView() {
const container = document.getElementById('main-viewport');
if (!container) return;
const tab = this.sm.state.activeTab || 'production';
const sector = this.sm.state.selectedSector || 'all';
this.currentMountedTab = tab;
this.currentMountedSector = sector;
// Keep header sector tabs in sync with selectedSector
this.mountHeaderSectorBar();
if (tab === 'production') {
mountProductionFloor(container, this.sm.state, (a) => this.handleAction(a), this.engine);
} else if (tab === 'unit_detail') {
mountUnitDetailView(container, this.sm.state, (a) => this.handleAction(a), this.engine);
} else if (tab === 'contracts') {
mountContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') {
mountBoardroom(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'milestones') {
mountAchievements(container, this.sm.state, (a) => this.handleAction(a));
}
}
/**
* Mounts the inspector panel ONCE when selected product changes (legacy stub)
*/
mountInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (inspectorContainer) {
this.currentMountedProduct = this.sm.state.selectedProductId;
mountInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
}
}
/**
* Executes fine-grained in-place element updates without innerHTML teardowns
*/
updateActiveView() {
const container = document.getElementById('main-viewport');
if (!container) return;
const tab = this.sm.state.activeTab || 'production';
if (tab !== this.currentMountedTab) {
this.mountActiveView();
return;
}
if (tab === 'production') {
updateProductionFloor(container, this.sm.state, this.engine);
} else if (tab === 'unit_detail') {
updateUnitDetailView(container, this.sm.state, this.engine);
} else if (tab === 'contracts') {
updateContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
} else if (tab === 'boardroom') {
updateBoardroom(container, this.sm.state);
} else if (tab === 'milestones') {
updateAchievements(container, this.sm.state);
}
}
updateInspectorPane() {
const inspectorContainer = document.getElementById('desktop-inspector-panel');
if (!inspectorContainer) return;
if (this.sm.state.selectedProductId !== this.currentMountedProduct) {
this.mountInspectorPane();
return;
}
updateInspector(inspectorContainer, this.sm.state);
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast-msg toast-${type}`;
toast.innerHTML = `
<iconify-icon icon="${type === 'success' ? 'lucide:check-circle' : type === 'warning' ? 'lucide:alert-circle' : 'lucide:info'}"></iconify-icon>
<span>${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 400);
}, 3200);
}
showConfirmModal({ title = 'Executive Authorization', subtitle = 'Board Confirmation', icon = 'lucide:alert-triangle', text = '', okText = 'Confirm', cancelText = 'Cancel' } = {}) {
return new Promise((resolve) => {
const modal = document.getElementById('confirm-modal');
const elTitle = document.getElementById('confirm-modal-title');
const elSub = document.getElementById('confirm-modal-subtitle');
const elIcon = document.getElementById('confirm-modal-icon');
const elText = document.getElementById('confirm-modal-text');
const btnOk = document.getElementById('btn-confirm-ok');
const btnCancel = document.getElementById('btn-confirm-cancel');
if (!modal) return resolve(false);
if (elTitle) elTitle.textContent = title;
if (elSub) elSub.textContent = subtitle;
if (elIcon) elIcon.setAttribute('icon', icon);
if (elText) elText.innerHTML = text;
if (btnOk) btnOk.textContent = okText;
if (btnCancel) btnCancel.textContent = cancelText;
const cleanup = () => {
modal.classList.remove('open');
btnOk.onclick = null;
btnCancel.onclick = null;
};
btnOk.onclick = () => {
cleanup();
resolve(true);
};
btnCancel.onclick = () => {
cleanup();
resolve(false);
};
modal.classList.add('open');
});
}
openAuthModal() {
const modal = document.getElementById('auth-modal');
const errBox = document.getElementById('auth-error-msg');
if (errBox) {
errBox.style.display = 'none';
errBox.textContent = '';
}
if (modal) modal.classList.add('open');
}
closeAuthModal() {
const modal = document.getElementById('auth-modal');
if (modal) modal.classList.remove('open');
}
showAuthError(msg) {
const errBox = document.getElementById('auth-error-msg');
if (errBox) {
errBox.textContent = msg;
errBox.style.display = 'block';
} else {
this.showToast(msg, 'warning');
}
}
async handleLogin() {
const u = document.getElementById('auth-username')?.value.trim();
const p = document.getElementById('auth-password')?.value;
if (!u || !p) return this.showAuthError('Please enter username and password');
try {
await this.sync.login(u, p);
this.showToast(`Logged in as ${u}`, 'success');
this.closeAuthModal();
this.updateHud();
} catch (err) {
this.showAuthError(err.message || 'Login failed');
}
}
async handleRegister() {
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 this.showAuthError('Please enter username and password');
try {
await this.sync.register(u, p, hp);
this.showToast(`Account created! Welcome ${u}`, 'success');
this.closeAuthModal();
this.updateHud();
} catch (err) {
this.showAuthError(err.message || 'Registration failed');
}
}
toggleSettings() {
const current = getNotation();
const next = current === 'standard' ? 'scientific' : 'standard';
setNotation(next);
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
this.mountActiveView();
this.mountInspectorPane();
}
}
// Bootstrap
window.addEventListener('DOMContentLoaded', () => {
const app = new ProductionApp();
app.init();
window.App = app;
});