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; // Production target / throttle (1.0 = 100%, 0.75 = 75%, 0.5 = 50%, 0.25 = 25%, 0.0 = 0% / paused) const targetThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0; if (targetThrottle <= 0.0) { pState.isBottlenecked = false; continue; } // Compute facility speed const milestoneMult = getUnitMilestoneMultiplier(lines); const cycleRate = (1.0 / p.baseCycleTime) * milestoneMult * roboticsSpeedMult * targetThrottle; // 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 { // Manufacturing logic: // Check if inputs can be consumed (only if upstream producer has supplyDownstreamEnabled !== false) const neededInputs = p.inputs.map(inp => ({ id: inp.id, amount: inp.amount * lines })); const canAccessAllInputs = neededInputs.every(inp => { const upstreamState = state.products[inp.id]; // If upstream has explicitly disabled downstream delivery, downstream cannot pull if (upstreamState && upstreamState.supplyDownstreamEnabled === false) return false; return (upstreamState?.stock || 0) >= inp.amount; }); if (pState.cycleProgress <= 0.0) { if (canAccessAllInputs) { for (const inp of neededInputs) { state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); } pState.isBottlenecked = false; pState.cycleProgress = Math.min(1.0, cycleRate * dt); } else { pState.isBottlenecked = true; } } else { pState.isBottlenecked = false; pState.cycleProgress += cycleRate * dt; } if (pState.cycleProgress >= 1.0) { pState.cycleProgress = 0.0; const produced = 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; } // Check if another batch can immediately start this tick const canStartNext = neededInputs.every(inp => { const upstreamState = state.products[inp.id]; if (upstreamState && upstreamState.supplyDownstreamEnabled === false) return false; return (upstreamState?.stock || 0) >= inp.amount; }); if (canStartNext) { for (const inp of neededInputs) { state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); } pState.isBottlenecked = false; } else { pState.isBottlenecked = true; } } } // Continuous Logistics Distribution (Wholesale Sales) // Respect logisticsEnabled flag (can be toggled on/off by player) const logisticsUnits = pState.logistics || 0; const isLogisticsActive = pState.logisticsEnabled !== false; if (isLogisticsActive && logisticsUnits > 0 && pState.stock > 0) { // If supply downstream is enabled, reserve stock for active downstream lines let reservedStock = 0; if (pState.supplyDownstreamEnabled !== false) { for (const otherP of PRODUCTS) { if (otherP.inputs && otherP.inputs.some(inp => inp.id === p.id)) { const otherState = state.products[otherP.id]; if (otherState && otherState.unlocked && otherState.lines > 0) { const inpDef = otherP.inputs.find(inp => inp.id === p.id); reservedStock = Math.max(reservedStock, inpDef.amount * otherState.lines); } } } } const availableToSell = Math.max(0, pState.stock - reservedStock); if (availableToSell > 0) { // Logistics fleet capacity: 0.75 items/sec per unit const maxSellCapacity = logisticsUnits * 0.75 * dt; const sold = Math.min(availableToSell, 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 = []; if (state.contractCooldown === undefined) state.contractCooldown = 0; 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 and trigger a cooldown before next tender state.activeContracts.splice(i, 1); state.contractCooldown = Math.max(state.contractCooldown, 10.0); } } // Count how many active tenders are fulfilled and ready to collect let readyCount = 0; for (const c of state.activeContracts) { const pState = state.products[c.productId]; if (pState && (pState.stock || 0) >= c.targetAmount) { readyCount++; } } state.readyContractsCount = readyCount; // Process replenishment cooldown if (state.contractCooldown > 0) { state.contractCooldown = Math.max(0, state.contractCooldown - dt); } // Replenish up to 3 open contracts only when cooldown is over if (state.activeContracts.length < 3 && state.contractCooldown <= 0) { 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 }); // If still under 3, stagger next replenish with a slight pause if (state.activeContracts.length < 3) { state.contractCooldown = 10.0; } } } } /** * 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 }; } }