diff --git a/backend/engine.py b/backend/engine.py index 7680f8f..3cb24d2 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -272,9 +272,17 @@ def process_offline_and_ticks(state: dict) -> dict: # Manufacturing logic with clean batch cycle state needed = [(inp['id'], inp['amount'] * lines) for inp in inputs] + def can_access_inputs(): + for item_id, req in needed: + upstream_state = state.get('products', {}).get(item_id, {}) + if upstream_state.get('supplyDownstreamEnabled') is False: + return False + if upstream_state.get('stock', 0.0) < req: + return False + return True + if p_state.get('cycleProgress', 0.0) <= 0.0: - can_start = all(state['products'].get(item_id, {}).get('stock', 0.0) >= req for item_id, req in needed) - if can_start: + if can_access_inputs(): 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) @@ -292,8 +300,7 @@ def process_offline_and_ticks(state: dict) -> dict: 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: + if can_access_inputs(): 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 @@ -302,15 +309,18 @@ def process_offline_and_ticks(state: dict) -> dict: # 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: + is_logistics_active = p_state.get('logisticsEnabled', True) is not False + + if is_logistics_active and logistics > 0 and p_state.get('stock', 0.0) > 0: 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'])) + if p_state.get('supplyDownstreamEnabled', True) is not False: + 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'])) avail = max(0.0, p_state.get('stock', 0.0) - reserved_stock) if avail > 0: @@ -374,6 +384,18 @@ def apply_actions(state: dict, actions: list) -> dict: state['cash'] -= cost state['products'][pid]['logistics'] = current_logistics + 1 + elif atype == 'TOGGLE_LOGISTICS': + pid = act.get('productId') + if pid in state.get('products', {}): + current = state['products'][pid].get('logisticsEnabled', True) is not False + state['products'][pid]['logisticsEnabled'] = not current + + elif atype == 'TOGGLE_DOWNSTREAM_SUPPLY': + pid = act.get('productId') + if pid in state.get('products', {}): + current = state['products'][pid].get('supplyDownstreamEnabled', True) is not False + state['products'][pid]['supplyDownstreamEnabled'] = not current + elif atype == 'TRIGGER_IPO': earned = calculate_shares_earned(state.get('lifetimeRevenue', 0.0)) if earned > 0: diff --git a/backend/test_engine.py b/backend/test_engine.py index d9fa698..41e47ec 100644 --- a/backend/test_engine.py +++ b/backend/test_engine.py @@ -92,10 +92,44 @@ def test_chained_microchips_and_boards(): 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']})") +def test_policy_toggles(): + state = engine.get_initial_state() + # Unlock wheat and flour + state['products']['wheat']['stock'] = 20.0 + state['products']['flour']['unlocked'] = True + state['products']['flour']['lines'] = 1 + + # 1. Turn off downstream supply on wheat + state = engine.apply_actions(state, [{'type': 'TOGGLE_DOWNSTREAM_SUPPLY', 'productId': 'wheat'}]) + assert state['products']['wheat']['supplyDownstreamEnabled'] is False + + # Simulate 5 seconds: flour should be bottlenecked because wheat supply to downstream is blocked + state['lastTick'] = time.time() - 5.0 + state = engine.process_offline_and_ticks(state) + assert state['products']['flour']['stock'] == 0.0 + assert state['products']['flour']['isBottlenecked'] is True + + # 2. Turn downstream supply back on: flour consumes wheat and produces + state = engine.apply_actions(state, [{'type': 'TOGGLE_DOWNSTREAM_SUPPLY', 'productId': 'wheat'}]) + assert state['products']['wheat']['supplyDownstreamEnabled'] is True + + # 3. Turn off logistics fleet on wheat + state = engine.apply_actions(state, [{'type': 'TOGGLE_LOGISTICS', 'productId': 'wheat'}]) + assert state['products']['wheat']['logisticsEnabled'] is False + + # Simulate 5 seconds: wheat stock will NOT be sold by logistics fleet + cash_before = state['cash'] + state['lastTick'] = time.time() - 5.0 + state = engine.process_offline_and_ticks(state) + assert state['cash'] == cash_before # Wheat logistics paused, no wholesale sales + assert state['products']['flour']['totalProduced'] > 0 # Flour received wheat! + print("PASS: test_policy_toggles") + if __name__ == '__main__': test_initial_state() test_raw_production_and_logistics() test_chained_dependency_and_bottleneck() test_chained_microchips_and_boards() + test_policy_toggles() test_ipo_and_shares() print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!") diff --git a/frontend/css/layout/workspace.css b/frontend/css/layout/workspace.css index 9659e86..7f0ec9f 100644 --- a/frontend/css/layout/workspace.css +++ b/frontend/css/layout/workspace.css @@ -216,20 +216,14 @@ color: var(--emerald-dark); } -/* All Sectors Overview Grid (Strict 6, 3, or 2 grid for 6 sectors) */ +/* All Sectors Overview Grid (Strict 3 columns on desktop, 2 columns on mobile/tablet) */ .all-sectors-summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } -@media (min-width: 1400px) { - .all-sectors-summary-grid { - grid-template-columns: repeat(6, 1fr); - } -} - -@media (max-width: 768px) { +@media (max-width: 900px) { .all-sectors-summary-grid { grid-template-columns: repeat(2, 1fr); } @@ -243,6 +237,7 @@ } + .sector-mini-card { background: var(--bg-surface); border: 1px solid var(--border-color); diff --git a/frontend/css/views/production.css b/frontend/css/views/production.css index 5a3c313..f5e6b1b 100644 --- a/frontend/css/views/production.css +++ b/frontend/css/views/production.css @@ -300,6 +300,62 @@ color: var(--amber-dark); } +/* Facility Logistics & Downstream Policy Bar */ +.facility-policy-bar { + display: flex; + gap: 6px; + align-items: center; + background: #f8fafc; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 4px 6px; +} + +.policy-label { + font-size: 0.68rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + margin-right: 2px; +} + +.btn-policy-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 3px 7px; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.btn-policy-toggle:hover { + border-color: var(--border-subtle); + background: var(--bg-surface-sub); +} + +.btn-policy-toggle.active { + background: #ecfdf5; + border-color: #6ee7b7; + color: var(--emerald-dark); + font-weight: 700; +} + +.btn-policy-toggle.inactive { + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; + font-weight: 700; +} + + /* Action Buttons */ .facility-actions-row { display: flex; diff --git a/frontend/js/app.js b/frontend/js/app.js index d91f100..6623d70 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -213,6 +213,34 @@ class ProductionApp { break; } + case 'TOGGLE_LOGISTICS': { + const p = getProduct(action.productId); + const pState = state.products[action.productId]; + if (p && pState) { + const nextVal = pState.logisticsEnabled === false ? true : false; + pState.logisticsEnabled = nextVal; + this.showToast(`${p.name} Wholesale Fleet: ${nextVal ? 'ENABLED' : 'PAUSED'}`, nextVal ? 'success' : 'info'); + this.sync.queueAction(action); + this.updateActiveView(); + this.updateInspectorPane(); + } + break; + } + + case 'TOGGLE_DOWNSTREAM_SUPPLY': { + const p = getProduct(action.productId); + const pState = state.products[action.productId]; + if (p && pState) { + const nextVal = pState.supplyDownstreamEnabled === false ? true : false; + pState.supplyDownstreamEnabled = nextVal; + this.showToast(`${p.name} Downstream Supply: ${nextVal ? 'ALLOWED' : 'BLOCKED'}`, nextVal ? 'success' : 'warning'); + this.sync.queueAction(action); + this.updateActiveView(); + this.updateInspectorPane(); + } + break; + } + case 'MANUAL_CYCLE': { const p = getProduct(action.productId); const pState = state.products[action.productId]; diff --git a/frontend/js/core/engine.js b/frontend/js/core/engine.js index e4056e4..ff77c1e 100644 --- a/frontend/js/core/engine.js +++ b/frontend/js/core/engine.js @@ -68,16 +68,21 @@ export class SimulationEngine { } } else { // Manufacturing logic: - // If cycle is ready to start (cycleProgress <= 0.0), check and reserve inputs. - // If already in progress, advance cycle until completion. + // 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) { - const canStart = neededInputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount); - if (canStart) { + if (canAccessAllInputs) { for (const inp of neededInputs) { state.products[inp.id].stock = Math.max(0, state.products[inp.id].stock - inp.amount); } @@ -102,7 +107,12 @@ export class SimulationEngine { } // Check if another batch can immediately start this tick - const canStartNext = neededInputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount); + 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); @@ -115,17 +125,21 @@ export class SimulationEngine { } // Continuous Logistics Distribution (Wholesale Sales) - // Intermediate goods required by active downstream lines have a reserved buffer so logistics won't starve them + // Respect logisticsEnabled flag (can be toggled on/off by player) const logisticsUnits = pState.logistics || 0; - if (logisticsUnits > 0 && pState.stock > 0) { - // Check if any active unlocked facility needs this product as an input + 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; - 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); + 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); + } } } } diff --git a/frontend/js/core/state.js b/frontend/js/core/state.js index 55d951c..c0f6c56 100644 --- a/frontend/js/core/state.js +++ b/frontend/js/core/state.js @@ -42,12 +42,15 @@ export function getInitialState() { lines: p.unlockedDefault ? 1 : 0, manager: false, logistics: p.unlockedDefault ? 1 : 0, + logisticsEnabled: true, + supplyDownstreamEnabled: true, cycleProgress: 0.0, isBottlenecked: false, totalProduced: 0.0 }; }); + // Initialize boardroom perks BOARDROOM_PERKS.forEach(perk => { state.boardroomPerks[perk.id] = 0; diff --git a/frontend/js/ui/inspectorView.js b/frontend/js/ui/inspectorView.js index 3a2b6df..b67bad4 100644 --- a/frontend/js/ui/inspectorView.js +++ b/frontend/js/ui/inspectorView.js @@ -53,6 +53,31 @@ export function mountInspector(container, state, onAction) { + +