import { PRODUCTS, SECTORS, getUnitMilestoneMultiplier } from '../data/products.js'; import { CONTRACT_TEMPLATES } from '../data/contracts.js'; import { ACHIEVEMENTS } from '../data/achievements.js'; export class SimulationEngine { constructor(stateManager) { this.sm = stateManager; this.cashRate = 0.0; this._rateAccumulator = 0.0; this._rateTimer = 0.0; this.sectorStats = {}; this._sectorAccumulators = {}; for (const s of SECTORS) { this.sectorStats[s.id] = { revenueRate: 0.0, unitsRate: 0.0, totalProduced: 0, activeLines: 0, bottlenecks: 0 }; this._sectorAccumulators[s.id] = { rev: 0.0, units: 0.0 }; } } /** * Executes a simulation tick for delta-time dt (in seconds) */ tick(dt) { if (dt <= 0) return; const state = this.sm.state; const perks = state.boardroomPerks || {}; // Boardroom Multipliers const supplyYieldMult = 1.0 + 0.15 * (perks['supply_sync'] || 0); const roboticsSpeedMult = 1.0 + 0.20 * (perks['robotics_overclock'] || 0); const logisticsPriceMult = 1.0 + 0.25 * (perks['logistics_efficiency'] || 0); let tickRevenue = 0.0; for (const p of PRODUCTS) { const pState = state.products[p.id]; if (!pState || !pState.unlocked) continue; const lines = pState.lines || 0; if (lines <= 0) continue; // Compute facility speed const milestoneMult = getUnitMilestoneMultiplier(lines); const cycleRate = (1.0 / p.baseCycleTime) * milestoneMult * roboticsSpeedMult; // Handle Raw Extraction vs Manufacturing Inputs const isRaw = !p.inputs || p.inputs.length === 0; if (isRaw) { pState.isBottlenecked = false; pState.cycleProgress += cycleRate * dt; if (pState.cycleProgress >= 1.0) { const cycles = Math.floor(pState.cycleProgress); pState.cycleProgress -= cycles; const produced = cycles * p.outputAmount * lines * supplyYieldMult; pState.stock += produced; pState.totalProduced = (pState.totalProduced || 0) + produced; state.stats.totalProduced = (state.stats.totalProduced || 0) + produced; if (this._sectorAccumulators[p.sector]) { this._sectorAccumulators[p.sector].units += produced; } } } else { // Check if we have required input stock const maxCyclesPossible = Math.min( ...p.inputs.map(inp => { const inputStock = state.products[inp.id]?.stock || 0; const reqPerCycle = inp.amount * lines; return reqPerCycle > 0 ? Math.floor(inputStock / reqPerCycle) : 0; }) ); if (maxCyclesPossible <= 0) { pState.isBottlenecked = true; // Retain existing cycle progress until inputs arrive } else { pState.isBottlenecked = false; pState.cycleProgress += cycleRate * dt; if (pState.cycleProgress >= 1.0) { const cyclesToRun = Math.min(Math.floor(pState.cycleProgress), maxCyclesPossible); if (cyclesToRun > 0) { pState.cycleProgress -= cyclesToRun; // Deduct inputs for (const inp of p.inputs) { if (state.products[inp.id]) { state.products[inp.id].stock = Math.max( 0, state.products[inp.id].stock - (inp.amount * lines * cyclesToRun) ); } } // Credit finished goods const produced = cyclesToRun * p.outputAmount * lines; pState.stock += produced; pState.totalProduced = (pState.totalProduced || 0) + produced; state.stats.totalProduced = (state.stats.totalProduced || 0) + produced; if (this._sectorAccumulators[p.sector]) { this._sectorAccumulators[p.sector].units += produced; } } } } } // Continuous Logistics Distribution (Wholesale Sales) const logisticsUnits = pState.logistics || 0; if (logisticsUnits > 0 && pState.stock > 0) { // Logistics fleet capacity: 0.75 items/sec per unit const maxSellCapacity = logisticsUnits * 0.75 * dt; const sold = Math.min(pState.stock, maxSellCapacity); pState.stock -= sold; const salePrice = p.basePrice * logisticsPriceMult; const earned = sold * salePrice; state.cash += earned; state.lifetimeRevenue += earned; tickRevenue += earned; if (this._sectorAccumulators[p.sector]) { this._sectorAccumulators[p.sector].rev += earned; } } } // Track net revenue rate ($/s) this._rateAccumulator += tickRevenue; this._rateTimer += dt; if (this._rateTimer >= 0.5) { this.cashRate = this._rateAccumulator / this._rateTimer; this._rateAccumulator = 0.0; for (const s of SECTORS) { const acc = this._sectorAccumulators[s.id] || { rev: 0, units: 0 }; const sec = this.sectorStats[s.id]; if (sec) { sec.revenueRate = acc.rev / this._rateTimer; sec.unitsRate = acc.units / this._rateTimer; acc.rev = 0.0; acc.units = 0.0; let totalProd = 0; let activeLines = 0; let bottlenecks = 0; for (const prod of PRODUCTS) { if (prod.sector === s.id) { const ps = state.products[prod.id]; if (ps && ps.unlocked) { totalProd += ps.totalProduced || 0; activeLines += ps.lines || 0; if (ps.isBottlenecked) bottlenecks++; } } } sec.totalProduced = totalProd; sec.activeLines = activeLines; sec.bottlenecks = bottlenecks; } } this._rateTimer = 0.0; } // Update company valuation: Liquid cash + Capital investment in lines + 20% of lifetime revenue let facilityAssetValuation = 0; for (const p of PRODUCTS) { const pState = state.products[p.id]; if (pState && pState.lines > 0) { facilityAssetValuation += pState.lines * p.baseFacilityCost; } } state.valuation = state.cash + facilityAssetValuation + (state.lifetimeRevenue * 0.15); // Update B2B contracts timers this.updateContracts(dt); // Check milestones/achievements this.checkAchievements(); state.lastTick = Date.now(); } /** * Advances and generates active procurement tenders */ updateContracts(dt) { const state = this.sm.state; if (!state.activeContracts) state.activeContracts = []; for (let i = state.activeContracts.length - 1; i >= 0; i--) { const c = state.activeContracts[i]; c.remainingSeconds -= dt; if (c.remainingSeconds <= 0) { // Expired contract, remove state.activeContracts.splice(i, 1); } } // Replenish up to 3 open contracts if (state.activeContracts.length < 3) { const unlockedProducts = PRODUCTS.filter(p => state.products[p.id]?.unlocked); if (unlockedProducts.length > 0) { const availableTemplates = CONTRACT_TEMPLATES.filter(tmpl => unlockedProducts.some(p => p.id === tmpl.productId) ); const tmpl = availableTemplates.length > 0 ? availableTemplates[Math.floor(Math.random() * availableTemplates.length)] : CONTRACT_TEMPLATES[0]; const uid = 'c_' + Date.now() + '_' + Math.floor(Math.random() * 1000); state.activeContracts.push({ uid, templateId: tmpl.id, productId: tmpl.productId, title: tmpl.title, clientId: tmpl.clientId, targetAmount: tmpl.requiredAmount, remainingSeconds: tmpl.timeLimit, rewardCash: tmpl.rewardCash, rewardRep: tmpl.rewardRep }); } } } /** * Checks and flags unlocked corporate achievements */ checkAchievements() { const state = this.sm.state; for (const ach of ACHIEVEMENTS) { let currentVal = 0; if (ach.reqType === 'total_produced') currentVal = state.stats.totalProduced || 0; else if (ach.reqType === 'total_revenue') currentVal = state.lifetimeRevenue || 0; else if (ach.reqType === 'contracts_completed') currentVal = state.stats.contractsCompleted || 0; else if (ach.reqType === 'ipo_count') currentVal = state.ipoCount || 0; else if (ach.reqType === 'managers_hired') currentVal = state.stats.managersHired || 0; const currentTier = state.achievements[ach.id] || 0; if (currentTier < ach.thresholds.length) { if (currentVal >= ach.thresholds[currentTier]) { state.achievements[ach.id] = currentTier + 1; } } } } /** * Simulates offline progression up to offlineSeconds (capped at 8 hours) */ processOffline(offlineSeconds) { const cappedSeconds = Math.min(Math.max(0, offlineSeconds), 8 * 3600); if (cappedSeconds <= 1) return { seconds: 0, earned: 0 }; const startCash = this.sm.state.cash; // Step forward in discrete 1-second chunks for accurate multi-tier consumption const steps = Math.min(Math.floor(cappedSeconds), 3600); const stepDt = cappedSeconds / steps; for (let i = 0; i < steps; i++) { this.tick(stepDt); } const earned = Math.max(0, this.sm.state.cash - startCash); return { seconds: cappedSeconds, earned }; } }