Add dependency navigation, production throttle control, and locked card heights to prevent grid jumping

This commit is contained in:
Christian 2026-10-16 12:16:06 +02:00
parent 1212224c19
commit 0a9a022fbc
9 changed files with 298 additions and 18 deletions

View file

@ -213,6 +213,9 @@ def get_initial_state() -> dict:
'lines': 1 if is_unlocked else 0, 'lines': 1 if is_unlocked else 0,
'manager': False, 'manager': False,
'logistics': 1 if is_unlocked else 0, 'logistics': 1 if is_unlocked else 0,
'logisticsEnabled': True,
'supplyDownstreamEnabled': True,
'productionTarget': 1.0,
'cycleProgress': 0.0, 'cycleProgress': 0.0,
'isBottlenecked': False, 'isBottlenecked': False,
'totalProduced': 0.0 'totalProduced': 0.0
@ -255,7 +258,12 @@ def process_offline_and_ticks(state: dict) -> dict:
continue continue
milestone_mult = get_unit_milestone_multiplier(lines) 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', []) inputs = p_cfg.get('inputs', [])
if not 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 current = state['products'][pid].get('supplyDownstreamEnabled', True) is not False
state['products'][pid]['supplyDownstreamEnabled'] = not current 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': elif atype == 'TRIGGER_IPO':
earned = calculate_shares_earned(state.get('lifetimeRevenue', 0.0)) earned = calculate_shares_earned(state.get('lifetimeRevenue', 0.0))
if earned > 0: if earned > 0:

View file

@ -125,11 +125,43 @@ def test_policy_toggles():
assert state['products']['flour']['totalProduced'] > 0 # Flour received wheat! assert state['products']['flour']['totalProduced'] > 0 # Flour received wheat!
print("PASS: test_policy_toggles") 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__': if __name__ == '__main__':
test_initial_state() test_initial_state()
test_raw_production_and_logistics() test_raw_production_and_logistics()
test_chained_dependency_and_bottleneck() test_chained_dependency_and_bottleneck()
test_chained_microchips_and_boards() test_chained_microchips_and_boards()
test_policy_toggles() test_policy_toggles()
test_production_target_throttle()
test_ipo_and_shares() test_ipo_and_shares()
print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!") print("\nALL BACKEND ENGINE TESTS PASSED SUCCESSFULLY!")

View file

@ -57,8 +57,9 @@
.production-grid { .production-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(390px, 1fr));
gap: 16px; gap: 16px;
align-items: stretch;
} }
.facility-card { .facility-card {
@ -70,8 +71,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
min-height: 420px; min-height: 440px;
gap: 12px; gap: 10px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
position: relative; position: relative;
@ -85,7 +86,29 @@
.facility-card.selected { .facility-card.selected {
border-color: var(--primary-blue); 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 { .facility-card.locked-card {
@ -228,7 +251,9 @@
background: var(--bg-surface-sub); background: var(--bg-surface-sub);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 8px 10px; padding: 8px 10px;
min-height: 84px; height: 86px;
min-height: 86px;
max-height: 86px;
box-sizing: border-box; box-sizing: border-box;
} }
@ -259,7 +284,7 @@
color: var(--amber-dark); color: var(--amber-dark);
} }
/* Inputs Strip */ /* Inputs Strip - Standardized fixed-height container to eliminate grid shifts */
.inputs-strip { .inputs-strip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -268,30 +293,55 @@
background: #f8fafc; background: #f8fafc;
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
border-radius: var(--radius-sm); 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 { .inputs-label {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.inputs-chips { .inputs-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
overflow-y: auto;
max-height: 44px;
scrollbar-width: thin;
} }
.input-chip { .input-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 0.72rem; font-size: 0.70rem;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
} }
.input-chip.low-stock { .input-chip.low-stock {
@ -355,6 +405,68 @@
font-weight: 700; 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 */ /* Action Buttons */
.facility-actions-row { .facility-actions-row {

View file

@ -144,10 +144,45 @@ class ProductionApp {
this.mountActiveView(); this.mountActiveView();
break; break;
case 'SELECT_PRODUCT': case 'SELECT_PRODUCT': {
state.selectedProductId = action.productId; 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(); 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; break;
}
case 'UNLOCK_PRODUCT': { case 'UNLOCK_PRODUCT': {
const p = getProduct(action.productId); const p = getProduct(action.productId);
@ -241,6 +276,21 @@ class ProductionApp {
break; 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': { case 'MANUAL_CYCLE': {
const p = getProduct(action.productId); const p = getProduct(action.productId);
const pState = state.products[action.productId]; const pState = state.products[action.productId];

View file

@ -44,9 +44,16 @@ export class SimulationEngine {
const lines = pState.lines || 0; const lines = pState.lines || 0;
if (lines <= 0) continue; 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 // Compute facility speed
const milestoneMult = getUnitMilestoneMultiplier(lines); 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 // Handle Raw Extraction vs Manufacturing Inputs
const isRaw = !p.inputs || p.inputs.length === 0; const isRaw = !p.inputs || p.inputs.length === 0;

View file

@ -44,6 +44,7 @@ export function getInitialState() {
logistics: p.unlockedDefault ? 1 : 0, logistics: p.unlockedDefault ? 1 : 0,
logisticsEnabled: true, logisticsEnabled: true,
supplyDownstreamEnabled: true, supplyDownstreamEnabled: true,
productionTarget: 1.0,
cycleProgress: 0.0, cycleProgress: 0.0,
isBottlenecked: false, isBottlenecked: false,
totalProduced: 0.0 totalProduced: 0.0

View file

@ -67,10 +67,27 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) {
</div> </div>
</div> </div>
`; `;
} else {
inputsHtml = `
<div class="inputs-strip raw-placeholder">
<span>Natural Raw Resource &bull; Direct extraction from deposits (No upstream inputs needed)</span>
</div>
`;
} }
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 = ''; let statusBadge = '';
if (pState.isBottlenecked) { if (currentThrottle <= 0.0) {
statusBadge = `<span class="status-badge badge-idle" id="badge-${p.id}"><iconify-icon icon="lucide:pause-circle"></iconify-icon> Throttled (0%)</span>`;
} else if (pState.isBottlenecked) {
statusBadge = `<span class="status-badge badge-warning" id="badge-${p.id}"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`; statusBadge = `<span class="status-badge badge-warning" id="badge-${p.id}"><iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs</span>`;
} else if (pState.lines > 0) { } else if (pState.lines > 0) {
statusBadge = `<span class="status-badge badge-success" id="badge-${p.id}"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`; statusBadge = `<span class="status-badge badge-success" id="badge-${p.id}"><iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)</span>`;
@ -137,6 +154,22 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) {
${inputsHtml} ${inputsHtml}
<!-- Facility Throttle Level Selector (100%, 75%, 50%, 25%, 0%) -->
<div class="facility-throttle-bar">
<span class="throttle-label">Throttle:</span>
<div class="throttle-pills">
${throttleLevels.map(lvl => {
const isMatch = Math.abs(currentThrottle - lvl.val) < 0.01;
const offClass = lvl.val === 0.0 ? 'pill-off' : '';
return `
<button class="btn-throttle-pill ${isMatch ? `active ${offClass}` : ''}" data-product="${p.id}" data-target="${lvl.val}">
${lvl.label}
</button>
`;
}).join('')}
</div>
</div>
<!-- Facility Supply & Distribution Policies --> <!-- Facility Supply & Distribution Policies -->
<div class="facility-policy-bar"> <div class="facility-policy-bar">
<span class="policy-label">Policy:</span> <span class="policy-label">Policy:</span>
@ -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 // Status Badge
const elBadge = document.getElementById(`badge-${p.id}`); const elBadge = document.getElementById(`badge-${p.id}`);
if (elBadge) { if (elBadge) {
const isBottlenecked = pState.isBottlenecked; if (currentThrottle <= 0.0) {
const hasWarning = elBadge.classList.contains('badge-warning'); elBadge.className = 'status-badge badge-idle';
if (isBottlenecked && !hasWarning) { elBadge.innerHTML = `<iconify-icon icon="lucide:pause-circle"></iconify-icon> Throttled (0%)`;
} else if (pState.isBottlenecked) {
elBadge.className = 'status-badge badge-warning'; elBadge.className = 'status-badge badge-warning';
elBadge.innerHTML = `<iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs`; elBadge.innerHTML = `<iconify-icon icon="lucide:alert-triangle"></iconify-icon> Waiting for Inputs`;
} else if (!isBottlenecked && hasWarning) { } else if (pState.lines > 0) {
const milestoneMult = getUnitMilestoneMultiplier(pState.lines); const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
const pctStr = currentThrottle < 1.0 ? ` (${Math.round(currentThrottle * 100)}%)` : '';
elBadge.className = 'status-badge badge-success'; elBadge.className = 'status-badge badge-success';
elBadge.innerHTML = `<iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed)`; elBadge.innerHTML = `<iconify-icon icon="lucide:check-circle"></iconify-icon> Operating (${milestoneMult}x Speed${pctStr})`;
} else {
elBadge.className = 'status-badge badge-idle';
elBadge.innerHTML = `Idle (0 Lines)`;
} }
} }

View file

@ -32,10 +32,11 @@ export function getProductOutputRates(p, pState, state) {
const milestoneMult = getUnitMilestoneMultiplier(pState.lines); const milestoneMult = getUnitMilestoneMultiplier(pState.lines);
const roboticsMult = state.upgrades?.automated_robotics ? 2.0 : 1.0; const roboticsMult = state.upgrades?.automated_robotics ? 2.0 : 1.0;
const baseCycleTime = Math.max(0.1, p.baseCycleTime / (milestoneMult * roboticsMult)); 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 // Output velocity: units per second
const cyclesPerSec = (1 / baseCycleTime); const cyclesPerSec = (1 / baseCycleTime) * targetThrottle;
const prodRate = isBottlenecked ? 0 : cyclesPerSec * p.outputAmount * pState.lines; const prodRate = isBottlenecked ? 0 : cyclesPerSec * p.outputAmount * pState.lines;
// Logistics revenue rate: items sold passively per second * price // Logistics revenue rate: items sold passively per second * price

View file

@ -72,6 +72,14 @@ export function mountProductionFloor(container, state, onAction, engine) {
container.querySelectorAll('.btn-toggle-downstream').forEach(btn => { container.querySelectorAll('.btn-toggle-downstream').forEach(btn => {
btn.onclick = () => onAction({ type: 'TOGGLE_DOWNSTREAM_SUPPLY', productId: btn.dataset.product }); 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)
});
});
} }
/** /**