From 0a9a022fbcb92e5cd63094b46c00cb13f68e617e Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 16 Oct 2026 12:16:06 +0200 Subject: [PATCH] Add dependency navigation, production throttle control, and locked card heights to prevent grid jumping --- backend/engine.py | 21 +++- backend/test_engine.py | 32 +++++ frontend/css/views/production.css | 126 +++++++++++++++++-- frontend/js/app.js | 52 +++++++- frontend/js/core/engine.js | 9 +- frontend/js/core/state.js | 1 + frontend/js/ui/production/facilityCard.js | 62 ++++++++- frontend/js/ui/production/ratesCalculator.js | 5 +- frontend/js/ui/productionView.js | 8 ++ 9 files changed, 298 insertions(+), 18 deletions(-) diff --git a/backend/engine.py b/backend/engine.py index 3cb24d2..7127b24 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -213,6 +213,9 @@ def get_initial_state() -> dict: 'lines': 1 if is_unlocked else 0, 'manager': False, 'logistics': 1 if is_unlocked else 0, + 'logisticsEnabled': True, + 'supplyDownstreamEnabled': True, + 'productionTarget': 1.0, 'cycleProgress': 0.0, 'isBottlenecked': False, 'totalProduced': 0.0 @@ -255,7 +258,12 @@ def process_offline_and_ticks(state: dict) -> dict: continue milestone_mult = get_unit_milestone_multiplier(lines) - cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult + target_throttle = float(p_state.get('productionTarget', 1.0)) + if target_throttle <= 0.0: + p_state['isBottlenecked'] = False + continue + + cycle_rate = (1.0 / p_cfg['baseCycleTime']) * milestone_mult * robotics_speed_mult * target_throttle inputs = p_cfg.get('inputs', []) if not inputs: @@ -396,6 +404,17 @@ def apply_actions(state: dict, actions: list) -> dict: current = state['products'][pid].get('supplyDownstreamEnabled', True) is not False state['products'][pid]['supplyDownstreamEnabled'] = not current + elif atype == 'SET_PRODUCTION_TARGET': + pid = act.get('productId') + target = act.get('target', 1.0) + if pid in state.get('products', {}): + try: + target_val = float(target) + if 0.0 <= target_val <= 1.0: + state['products'][pid]['productionTarget'] = target_val + except (ValueError, TypeError): + pass + 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 41e47ec..dbfd16d 100644 --- a/backend/test_engine.py +++ b/backend/test_engine.py @@ -125,11 +125,43 @@ def test_policy_toggles(): assert state['products']['flour']['totalProduced'] > 0 # Flour received wheat! print("PASS: test_policy_toggles") +def test_production_target_throttle(): + state = engine.get_initial_state() + state['products']['wheat']['logisticsEnabled'] = False # Don't sell to isolate production rate + + # Set throttle to 0.5 (50%) + state = engine.apply_actions(state, [{'type': 'SET_PRODUCTION_TARGET', 'productId': 'wheat', 'target': 0.5}]) + assert state['products']['wheat']['productionTarget'] == 0.5 + + state['lastTick'] = time.time() - 10.0 + state = engine.process_offline_and_ticks(state) + produced_at_50 = state['products']['wheat']['totalProduced'] + + # Reset and test at 1.0 (100%) + state2 = engine.get_initial_state() + state2['products']['wheat']['logisticsEnabled'] = False + state2['lastTick'] = time.time() - 10.0 + state2 = engine.process_offline_and_ticks(state2) + produced_at_100 = state2['products']['wheat']['totalProduced'] + + # 50% should produce roughly half (5 units vs 10 units over 10s at 1.0s base cycle) + assert produced_at_50 < produced_at_100 + assert abs(produced_at_50 - (produced_at_100 * 0.5)) <= 1.0 + + # Set throttle to 0.0 (Paused / Off) + state = engine.apply_actions(state, [{'type': 'SET_PRODUCTION_TARGET', 'productId': 'wheat', 'target': 0.0}]) + stock_before = state['products']['wheat']['stock'] + state['lastTick'] = time.time() - 10.0 + state = engine.process_offline_and_ticks(state) + assert state['products']['wheat']['stock'] == stock_before # Zero production when throttled to 0% + print(f"PASS: test_production_target_throttle (50%: {produced_at_50}, 100%: {produced_at_100})") + 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_production_target_throttle() test_ipo_and_shares() print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!") diff --git a/frontend/css/views/production.css b/frontend/css/views/production.css index f5e6b1b..8fdb513 100644 --- a/frontend/css/views/production.css +++ b/frontend/css/views/production.css @@ -57,8 +57,9 @@ .production-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); gap: 16px; + align-items: stretch; } .facility-card { @@ -70,8 +71,8 @@ display: flex; flex-direction: column; justify-content: space-between; - min-height: 420px; - gap: 12px; + min-height: 440px; + gap: 10px; cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; position: relative; @@ -85,7 +86,29 @@ .facility-card.selected { border-color: var(--primary-blue); - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); +} + +/* Card Navigation Highlight Pulse */ +.facility-card.card-highlight-pulse { + animation: cardPulseAnim 1.5s ease-out; +} + +@keyframes cardPulseAnim { + 0% { + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.7); + border-color: var(--primary-blue); + transform: scale(1.02); + } + 50% { + box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); + border-color: var(--primary-blue); + transform: scale(1.01); + } + 100% { + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); + transform: scale(1.0); + } } .facility-card.locked-card { @@ -228,7 +251,9 @@ background: var(--bg-surface-sub); border-radius: var(--radius-sm); padding: 8px 10px; - min-height: 84px; + height: 86px; + min-height: 86px; + max-height: 86px; box-sizing: border-box; } @@ -259,7 +284,7 @@ color: var(--amber-dark); } -/* Inputs Strip */ +/* Inputs Strip - Standardized fixed-height container to eliminate grid shifts */ .inputs-strip { display: flex; flex-direction: column; @@ -268,30 +293,55 @@ background: #f8fafc; border: 1px dashed var(--border-color); border-radius: var(--radius-sm); + height: 72px; + min-height: 72px; + max-height: 72px; + box-sizing: border-box; + overflow: hidden; +} + +.inputs-strip.raw-placeholder { + justify-content: center; + align-items: center; + background: #fafaf9; + border-style: dotted; + color: var(--text-muted); + font-size: 0.72rem; + font-style: italic; + text-align: center; + padding: 8px; } .inputs-label { font-size: 0.68rem; font-weight: 700; color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .inputs-chips { display: flex; flex-wrap: wrap; gap: 6px; + overflow-y: auto; + max-height: 44px; + scrollbar-width: thin; } .input-chip { display: inline-flex; align-items: center; gap: 4px; - font-size: 0.72rem; + font-size: 0.70rem; background: var(--bg-surface); border: 1px solid var(--border-color); padding: 2px 6px; border-radius: 4px; color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; } .input-chip.low-stock { @@ -355,6 +405,68 @@ font-weight: 700; } +/* Facility Throttle Control Bar */ +.facility-throttle-bar { + display: flex; + gap: 6px; + align-items: center; + background: #f8fafc; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 3px 6px; + box-sizing: border-box; + height: 32px; +} + +.throttle-label { + font-size: 0.68rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + margin-right: 2px; + white-space: nowrap; +} + +.throttle-pills { + display: flex; + gap: 4px; + flex: 1; +} + +.btn-throttle-pill { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 2px 0; + font-size: 0.70rem; + font-weight: 600; + color: var(--text-secondary); + cursor: pointer; + text-align: center; + transition: all 0.15s; + user-select: none; +} + +.btn-throttle-pill:hover { + border-color: var(--border-subtle); + background: var(--bg-surface-sub); +} + +.btn-throttle-pill.active { + background: var(--primary-blue); + border-color: var(--primary-blue); + color: #ffffff; + font-weight: 700; +} + +.btn-throttle-pill.active.pill-off { + background: #ef4444; + border-color: #dc2626; + color: #ffffff; +} + /* Action Buttons */ .facility-actions-row { diff --git a/frontend/js/app.js b/frontend/js/app.js index 6623d70..8bb4cdd 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -144,10 +144,45 @@ class ProductionApp { this.mountActiveView(); break; - case 'SELECT_PRODUCT': + case 'SELECT_PRODUCT': { state.selectedProductId = action.productId; + const targetP = getProduct(action.productId); + + // If user selected a dependency or unit from inspector, ensure we navigate / go to that facility card + let needsViewMount = false; + if (state.activeTab !== 'production') { + state.activeTab = 'production'; + document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => { + b.classList.toggle('active', b.dataset.tab === 'production'); + }); + needsViewMount = true; + } + + if (targetP && state.selectedSector !== 'all' && state.selectedSector !== targetP.sector) { + state.selectedSector = targetP.sector; + needsViewMount = true; + } + + if (needsViewMount) { + this.mountActiveView(); + } this.mountInspectorPane(); + + // Smooth scroll to the facility card and highlight it + requestAnimationFrame(() => { + const cardEl = document.getElementById('card-' + action.productId); + if (cardEl) { + cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + cardEl.classList.remove('card-highlight-pulse'); + void cardEl.offsetWidth; // Force CSS reflow to re-trigger animation + cardEl.classList.add('card-highlight-pulse'); + setTimeout(() => { + cardEl.classList.remove('card-highlight-pulse'); + }, 1600); + } + }); break; + } case 'UNLOCK_PRODUCT': { const p = getProduct(action.productId); @@ -241,6 +276,21 @@ class ProductionApp { break; } + case 'SET_PRODUCTION_TARGET': { + const p = getProduct(action.productId); + const pState = state.products[action.productId]; + if (p && pState) { + const target = Math.max(0, Math.min(1.0, action.target)); + pState.productionTarget = target; + const pct = Math.round(target * 100); + this.showToast(`${p.name} Production Target set to ${pct}%`, 'info'); + 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 ff77c1e..d7a1e0f 100644 --- a/frontend/js/core/engine.js +++ b/frontend/js/core/engine.js @@ -44,9 +44,16 @@ export class SimulationEngine { const lines = pState.lines || 0; if (lines <= 0) continue; + // Production target / throttle (1.0 = 100%, 0.75 = 75%, 0.5 = 50%, 0.25 = 25%, 0.0 = 0% / paused) + const targetThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0; + if (targetThrottle <= 0.0) { + pState.isBottlenecked = false; + continue; + } + // Compute facility speed const milestoneMult = getUnitMilestoneMultiplier(lines); - const cycleRate = (1.0 / p.baseCycleTime) * milestoneMult * roboticsSpeedMult; + const cycleRate = (1.0 / p.baseCycleTime) * milestoneMult * roboticsSpeedMult * targetThrottle; // Handle Raw Extraction vs Manufacturing Inputs const isRaw = !p.inputs || p.inputs.length === 0; diff --git a/frontend/js/core/state.js b/frontend/js/core/state.js index c0f6c56..edaea44 100644 --- a/frontend/js/core/state.js +++ b/frontend/js/core/state.js @@ -44,6 +44,7 @@ export function getInitialState() { logistics: p.unlockedDefault ? 1 : 0, logisticsEnabled: true, supplyDownstreamEnabled: true, + productionTarget: 1.0, cycleProgress: 0.0, isBottlenecked: false, totalProduced: 0.0 diff --git a/frontend/js/ui/production/facilityCard.js b/frontend/js/ui/production/facilityCard.js index f14948b..39ce762 100644 --- a/frontend/js/ui/production/facilityCard.js +++ b/frontend/js/ui/production/facilityCard.js @@ -67,10 +67,27 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) { `; + } else { + inputsHtml = ` +
+ Natural Raw Resource • Direct extraction from deposits (No upstream inputs needed) +
+ `; } + const currentThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0; + const throttleLevels = [ + { val: 1.0, label: '100%' }, + { val: 0.75, label: '75%' }, + { val: 0.50, label: '50%' }, + { val: 0.25, label: '25%' }, + { val: 0.0, label: '0% (Off)' } + ]; + let statusBadge = ''; - if (pState.isBottlenecked) { + if (currentThrottle <= 0.0) { + statusBadge = ` Throttled (0%)`; + } else if (pState.isBottlenecked) { statusBadge = ` Waiting for Inputs`; } else if (pState.lines > 0) { statusBadge = ` Operating (${milestoneMult}x Speed)`; @@ -137,6 +154,22 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) { ${inputsHtml} + +
+ Throttle: +
+ ${throttleLevels.map(lvl => { + const isMatch = Math.abs(currentThrottle - lvl.val) < 0.01; + const offClass = lvl.val === 0.0 ? 'pill-off' : ''; + return ` + + `; + }).join('')} +
+
+
Policy: @@ -296,18 +329,35 @@ export function updateFacilityCardDom(p, pState, state, buyMode) { } } + // Throttle buttons DOM state + const currentThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0; + const cardEl = document.getElementById(`card-${p.id}`); + if (cardEl) { + cardEl.querySelectorAll('.btn-throttle-pill').forEach(btn => { + const btnVal = parseFloat(btn.dataset.target); + const isMatch = Math.abs(currentThrottle - btnVal) < 0.01; + const offClass = btnVal === 0.0 ? 'pill-off' : ''; + btn.className = `btn-throttle-pill ${isMatch ? `active ${offClass}` : ''}`; + }); + } + // Status Badge const elBadge = document.getElementById(`badge-${p.id}`); if (elBadge) { - const isBottlenecked = pState.isBottlenecked; - const hasWarning = elBadge.classList.contains('badge-warning'); - if (isBottlenecked && !hasWarning) { + if (currentThrottle <= 0.0) { + elBadge.className = 'status-badge badge-idle'; + elBadge.innerHTML = ` Throttled (0%)`; + } else if (pState.isBottlenecked) { elBadge.className = 'status-badge badge-warning'; elBadge.innerHTML = ` Waiting for Inputs`; - } else if (!isBottlenecked && hasWarning) { + } else if (pState.lines > 0) { const milestoneMult = getUnitMilestoneMultiplier(pState.lines); + const pctStr = currentThrottle < 1.0 ? ` (${Math.round(currentThrottle * 100)}%)` : ''; elBadge.className = 'status-badge badge-success'; - elBadge.innerHTML = ` Operating (${milestoneMult}x Speed)`; + elBadge.innerHTML = ` Operating (${milestoneMult}x Speed${pctStr})`; + } else { + elBadge.className = 'status-badge badge-idle'; + elBadge.innerHTML = `Idle (0 Lines)`; } } diff --git a/frontend/js/ui/production/ratesCalculator.js b/frontend/js/ui/production/ratesCalculator.js index 5a87e4d..b1a865e 100644 --- a/frontend/js/ui/production/ratesCalculator.js +++ b/frontend/js/ui/production/ratesCalculator.js @@ -32,10 +32,11 @@ export function getProductOutputRates(p, pState, state) { const milestoneMult = getUnitMilestoneMultiplier(pState.lines); const roboticsMult = state.upgrades?.automated_robotics ? 2.0 : 1.0; const baseCycleTime = Math.max(0.1, p.baseCycleTime / (milestoneMult * roboticsMult)); - const isBottlenecked = !!pState.isBottlenecked; + const targetThrottle = pState.productionTarget !== undefined ? pState.productionTarget : 1.0; + const isBottlenecked = !!pState.isBottlenecked || targetThrottle <= 0; // Output velocity: units per second - const cyclesPerSec = (1 / baseCycleTime); + const cyclesPerSec = (1 / baseCycleTime) * targetThrottle; const prodRate = isBottlenecked ? 0 : cyclesPerSec * p.outputAmount * pState.lines; // Logistics revenue rate: items sold passively per second * price diff --git a/frontend/js/ui/productionView.js b/frontend/js/ui/productionView.js index 1da3bc4..ed84706 100644 --- a/frontend/js/ui/productionView.js +++ b/frontend/js/ui/productionView.js @@ -72,6 +72,14 @@ export function mountProductionFloor(container, state, onAction, engine) { container.querySelectorAll('.btn-toggle-downstream').forEach(btn => { btn.onclick = () => onAction({ type: 'TOGGLE_DOWNSTREAM_SUPPLY', productId: btn.dataset.product }); }); + + container.querySelectorAll('.btn-throttle-pill').forEach(btn => { + btn.onclick = () => onAction({ + type: 'SET_PRODUCTION_TARGET', + productId: btn.dataset.product, + target: parseFloat(btn.dataset.target) + }); + }); } /**