From 032b0cc255ecb74128292953b7b9ea95274de8f3 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 00:17:20 +0200 Subject: [PATCH] Fix production batch cycle input deduction and downstream stock reservation --- backend/engine.py | 64 ++++++++++++++---------- backend/test_engine.py | 25 ++++++++++ frontend/js/core/engine.js | 100 ++++++++++++++++++++----------------- 3 files changed, 119 insertions(+), 70 deletions(-) diff --git a/backend/engine.py b/backend/engine.py index 13a90b6..7680f8f 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -269,44 +269,58 @@ def process_offline_and_ticks(state: dict) -> dict: p_state['stock'] = p_state.get('stock', 0.0) + produced p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced else: - # Manufacturing logic with input batch reservation + # Manufacturing logic with clean batch cycle state needed = [(inp['id'], inp['amount'] * lines) for inp in inputs] - can_start = all(state['products'].get(item_id, {}).get('stock', 0.0) >= req for item_id, req in needed) if p_state.get('cycleProgress', 0.0) <= 0.0: - if not can_start: - p_state['isBottlenecked'] = True - else: + can_start = all(state['products'].get(item_id, {}).get('stock', 0.0) >= req for item_id, req in needed) + if can_start: p_state['isBottlenecked'] = False for item_id, req in needed: state['products'][item_id]['stock'] = max(0.0, state['products'][item_id].get('stock', 0.0) - req) - p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt) + p_state['cycleProgress'] = min(1.0, cycle_rate * dt) + else: + p_state['isBottlenecked'] = True else: p_state['isBottlenecked'] = False p_state['cycleProgress'] = p_state.get('cycleProgress', 0.0) + (cycle_rate * dt) - if p_state['cycleProgress'] >= 1.0: - p_state['cycleProgress'] = 0.0 - produced = p_cfg['outputAmount'] * lines - p_state['stock'] = p_state.get('stock', 0.0) + produced - p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced - - # Check next batch - if can_start: - for item_id, req in needed: - state['products'][item_id]['stock'] = max(0.0, state['products'][item_id].get('stock', 0.0) - req) - else: - p_state['isBottlenecked'] = True - # Passive Logistics Sales + if p_state.get('cycleProgress', 0.0) >= 1.0: + p_state['cycleProgress'] = 0.0 + produced = p_cfg['outputAmount'] * lines + p_state['stock'] = p_state.get('stock', 0.0) + produced + p_state['totalProduced'] = p_state.get('totalProduced', 0.0) + produced + + # Check next batch + can_start_next = all(state['products'].get(item_id, {}).get('stock', 0.0) >= req for item_id, req in needed) + if can_start_next: + for item_id, req in needed: + state['products'][item_id]['stock'] = max(0.0, state['products'][item_id].get('stock', 0.0) - req) + p_state['isBottlenecked'] = False + else: + p_state['isBottlenecked'] = True + + # Passive Logistics Sales with stock reservation for downstream factories logistics = p_state.get('logistics', 0) if logistics > 0 and p_state.get('stock', 0.0) > 0: - max_sell = logistics * 0.75 * dt - sold = min(p_state['stock'], max_sell) - p_state['stock'] -= sold + reserved_stock = 0.0 + for other_pid, other_cfg in PRODUCT_CONFIGS.items(): + other_inputs = other_cfg.get('inputs', []) + if any(inp['id'] == pid for inp in other_inputs): + other_state = state.get('products', {}).get(other_pid) + if other_state and other_state.get('unlocked') and other_state.get('lines', 0) > 0: + inp_match = next(inp for inp in other_inputs if inp['id'] == pid) + reserved_stock = max(reserved_stock, float(inp_match['amount'] * other_state['lines'])) - earned = sold * p_cfg['basePrice'] * logistics_price_mult - state['cash'] = state.get('cash', 0.0) + earned - state['lifetimeRevenue'] = state.get('lifetimeRevenue', 0.0) + earned + avail = max(0.0, p_state.get('stock', 0.0) - reserved_stock) + if avail > 0: + max_sell = logistics * 0.75 * dt + sold = min(avail, max_sell) + p_state['stock'] -= sold + + earned = sold * p_cfg['basePrice'] * logistics_price_mult + state['cash'] = state.get('cash', 0.0) + earned + state['lifetimeRevenue'] = state.get('lifetimeRevenue', 0.0) + earned # Update valuation facility_assets = sum( diff --git a/backend/test_engine.py b/backend/test_engine.py index 616be35..d9fa698 100644 --- a/backend/test_engine.py +++ b/backend/test_engine.py @@ -68,9 +68,34 @@ def test_ipo_and_shares(): assert state['cash'] == 30.0 print("PASS: test_ipo_and_shares") +def test_chained_microchips_and_boards(): + state = engine.get_initial_state() + # Unlock silicon, polymers, microchips, circuit_boards + for pid in ['silicon', 'polymers', 'microchips', 'circuit_boards']: + state['products'][pid]['unlocked'] = True + state['products'][pid]['lines'] = 1 + state['products'][pid]['stock'] = 0.0 + + # Give raw materials so silicon and polymers don't bottleneck + state['products']['silicon']['stock'] = 10.0 + state['products']['polymers']['stock'] = 20.0 + + # Microchips has 1 logistics fleet selling, but circuit_boards needs 2 microchips + state['products']['microchips']['logistics'] = 1 + + # Simulate 30 seconds + state['lastTick'] = time.time() - 30.0 + state = engine.process_offline_and_ticks(state) + + # Verify microchips produced and circuit_boards actually received microchips and produced boards! + assert state['products']['microchips']['totalProduced'] > 0 + assert state['products']['circuit_boards']['totalProduced'] > 0 + print(f"PASS: test_chained_microchips_and_boards (Chips: {state['products']['microchips']['totalProduced']}, Boards: {state['products']['circuit_boards']['totalProduced']})") + if __name__ == '__main__': test_initial_state() test_raw_production_and_logistics() test_chained_dependency_and_bottleneck() + test_chained_microchips_and_boards() test_ipo_and_shares() print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!") diff --git a/frontend/js/core/engine.js b/frontend/js/core/engine.js index faa061e..e4056e4 100644 --- a/frontend/js/core/engine.js +++ b/frontend/js/core/engine.js @@ -67,74 +67,84 @@ export class SimulationEngine { } } } else { - // Manufacturing logic with input batch reservation: - // Check if factory is currently executing an in-progress cycle or needs to start one + // 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 })); - const canStartBatch = neededInputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount); - if (pState.cycleProgress <= 0.0) { - // Not currently running a cycle - if (!canStartBatch) { - pState.isBottlenecked = true; - } else { - // Start batch by reserving required inputs - pState.isBottlenecked = false; + const canStart = neededInputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount); + if (canStart) { for (const inp of neededInputs) { - if (state.products[inp.id]) { - state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); - } + state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); } - pState.cycleProgress += cycleRate * dt; + pState.isBottlenecked = false; + pState.cycleProgress = Math.min(1.0, cycleRate * dt); + } else { + pState.isBottlenecked = true; } } else { - // Cycle already in progress (inputs already secured) pState.isBottlenecked = false; pState.cycleProgress += cycleRate * dt; + } - if (pState.cycleProgress >= 1.0) { - // Finished cycle, deliver produced goods - 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; - } + 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; + } - // Immediately check if next batch can start - if (canStartBatch) { - for (const inp of neededInputs) { - if (state.products[inp.id]) { - state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); - } - } - } else { - pState.isBottlenecked = true; + // 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) { - // 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; + // 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 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; + 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; + } } } }