296 lines
12 KiB
JavaScript
296 lines
12 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;
|
|
|
|
// 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 = [];
|
|
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 };
|
|
}
|
|
}
|