336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
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 };
|
|
}
|
|
}
|