ProductionGame/frontend/js/core/engine.js

226 lines
9 KiB
JavaScript

// Production - The Idle Game: Core Simulation Engine
import { PRODUCTS, 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;
}
/**
* 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;
}
} 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;
}
}
}
}
// 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;
}
}
// 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;
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 };
}
}