ProductionGame/frontend/js/core/engine.js

315 lines
13 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 {
// Manufacturing logic:
// If cycle is ready to start (cycleProgress <= 0.0), check and reserve inputs.
// If already in progress, advance cycle until completion.
const neededInputs = p.inputs.map(inp => ({
id: inp.id,
amount: inp.amount * lines
}));
if (pState.cycleProgress <= 0.0) {
const canStart = neededInputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount);
if (canStart) {
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 => (state.products[inp.id]?.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)
// Intermediate goods required by active downstream lines have a reserved buffer so logistics won't starve them
const logisticsUnits = pState.logistics || 0;
if (logisticsUnits > 0 && pState.stock > 0) {
// Check if any active unlocked facility needs this product as an input
let reservedStock = 0;
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 };
}
}