Add Distribution Fleet toggle, Downstream Supply toggle, and enforce 3x2 grid on All Sectors

This commit is contained in:
Christian 2026-10-16 11:35:29 +02:00
parent 21cb0aeb50
commit ff8983273e
11 changed files with 328 additions and 54 deletions

View file

@ -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,8 +309,11 @@ 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
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):
@ -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:

View file

@ -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!")

View file

@ -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);

View file

@ -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;

View file

@ -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];

View file

@ -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,11 +125,14 @@ 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;
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];
@ -129,6 +142,7 @@ export class SimulationEngine {
}
}
}
}
const availableToSell = Math.max(0, pState.stock - reservedStock);
if (availableToSell > 0) {

View file

@ -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;

View file

@ -53,6 +53,31 @@ export function mountInspector(container, state, onAction) {
</div>
</div>
<!-- Distribution Fleet & Downstream Supply Policies -->
<div class="inspector-section">
<h4 class="section-heading">
<iconify-icon icon="lucide:sliders"></iconify-icon> Supply & Distribution Policies
</h4>
<div class="liquidation-buttons">
<button class="btn-corp-secondary" id="insp-btn-toggle-logistics" data-product="${product.id}" title="Toggle Wholesale Fleet on/off">
<span class="liquidation-btn-label">
<iconify-icon icon="${(pState.logisticsEnabled !== false) ? 'lucide:truck' : 'lucide:truck-x'}"></iconify-icon> Wholesale Fleet
</span>
<span class="liquidation-btn-val" id="insp-toggle-log-status" style="color: ${(pState.logisticsEnabled !== false) ? 'var(--emerald-dark)' : 'var(--amber-dark)'};">
${(pState.logisticsEnabled !== false) ? 'ACTIVE' : 'PAUSED'}
</span>
</button>
<button class="btn-corp-secondary" id="insp-btn-toggle-downstream" data-product="${product.id}" title="Toggle Raw Delivery to Downstream Consumers on/off">
<span class="liquidation-btn-label">
<iconify-icon icon="${(pState.supplyDownstreamEnabled !== false) ? 'lucide:git-pull-request' : 'lucide:git-pull-request-closed'}"></iconify-icon> Downstream
</span>
<span class="liquidation-btn-val" id="insp-toggle-down-status" style="color: ${(pState.supplyDownstreamEnabled !== false) ? 'var(--emerald-dark)' : 'var(--amber-dark)'};">
${(pState.supplyDownstreamEnabled !== false) ? 'ALLOWED' : 'BLOCKED'}
</span>
</button>
</div>
</div>
<!-- Instant Wholesale Market Liquidation -->
<div class="inspector-section">
<h4 class="section-heading">
@ -145,6 +170,16 @@ export function mountInspector(container, state, onAction) {
allBtn.onclick = () => onAction({ type: 'LIQUIDATE_STOCK', productId: product.id, ratio: 1.0 });
}
const toggleLogBtn = container.querySelector('#insp-btn-toggle-logistics');
if (toggleLogBtn) {
toggleLogBtn.onclick = () => onAction({ type: 'TOGGLE_LOGISTICS', productId: product.id });
}
const toggleDownBtn = container.querySelector('#insp-btn-toggle-downstream');
if (toggleDownBtn) {
toggleDownBtn.onclick = () => onAction({ type: 'TOGGLE_DOWNSTREAM_SUPPLY', productId: product.id });
}
container.querySelectorAll('.chain-item-row').forEach(row => {
row.onclick = () => onAction({ type: 'SELECT_PRODUCT', productId: row.dataset.product });
});
@ -166,6 +201,21 @@ export function updateInspector(container, state) {
elStock.textContent = `${fmtNum(pState.stock, 0)} ${product.unit}`;
}
// Update policy statuses
const isLogActive = pState.logisticsEnabled !== false;
const elLogStatus = document.getElementById('insp-toggle-log-status');
if (elLogStatus) {
elLogStatus.textContent = isLogActive ? 'ACTIVE' : 'PAUSED';
elLogStatus.style.color = isLogActive ? 'var(--emerald-dark)' : 'var(--amber-dark)';
}
const isDownActive = pState.supplyDownstreamEnabled !== false;
const elDownStatus = document.getElementById('insp-toggle-down-status');
if (elDownStatus) {
elDownStatus.textContent = isDownActive ? 'ALLOWED' : 'BLOCKED';
elDownStatus.style.color = isDownActive ? 'var(--emerald-dark)' : 'var(--amber-dark)';
}
// Update liquidation values & buttons
const totalLiquidationValue = (pState.stock || 0) * product.basePrice;
const halfLiquidationValue = totalLiquidationValue * 0.5;

View file

@ -127,12 +127,29 @@ export function renderFacilityCardHtml(p, pState, state, buyMode) {
</div>
<div class="stat-cell" title="Logistics fleets automatically sell stockpiled goods continuously onto the wholesale market (0.75 units/sec per fleet)">
<span class="stat-label">Logistics Auto-Sell:</span>
<span class="stat-value font-mono" id="logistics-${p.id}">${pState.logistics || 0} fleets (${fmtNum((pState.logistics || 0) * 0.75, 2)}/s)</span>
<span class="stat-value font-mono" id="logistics-${p.id}">
${pState.logistics || 0} fleets (${(pState.logisticsEnabled !== false) ? `${fmtNum((pState.logistics || 0) * 0.75, 2)}/s` : 'Paused'})
</span>
</div>
</div>
${inputsHtml}
<!-- Facility Supply & Distribution Policies -->
<div class="facility-policy-bar">
<span class="policy-label">Policy:</span>
<button class="btn-policy-toggle ${(pState.logisticsEnabled !== false) ? 'active' : 'inactive'} btn-toggle-logistics" id="btn-toggle-logistics-${p.id}" data-product="${p.id}" title="Toggle Wholesale Distribution Fleet on/off">
<iconify-icon icon="${(pState.logisticsEnabled !== false) ? 'lucide:truck' : 'lucide:truck-x'}"></iconify-icon>
<span id="btn-toggle-logistics-text-${p.id}">Wholesale Fleet: ${(pState.logisticsEnabled !== false) ? 'ON' : 'OFF'}</span>
</button>
<button class="btn-policy-toggle ${(pState.supplyDownstreamEnabled !== false) ? 'active' : 'inactive'} btn-toggle-downstream" id="btn-toggle-downstream-${p.id}" data-product="${p.id}" title="Toggle raw material supply to downstream manufacturing factories on/off">
<iconify-icon icon="${(pState.supplyDownstreamEnabled !== false) ? 'lucide:git-pull-request' : 'lucide:git-pull-request-closed'}"></iconify-icon>
<span id="btn-toggle-downstream-text-${p.id}">Downstream Supply: ${(pState.supplyDownstreamEnabled !== false) ? 'ON' : 'OFF'}</span>
</button>
</div>
<!-- Facility Actions Bar -->
<div class="facility-actions-row">
<button class="btn-corp-action btn-cycle" data-product="${p.id}" title="Run 1 manual production cycle">
@ -223,12 +240,45 @@ export function updateFacilityCardDom(p, pState, state, buyMode) {
const elLogistics = document.getElementById(`logistics-${p.id}`);
if (elLogistics) {
const count = pState.logistics || 0;
const logText = `${count} fleets (${fmtNum(count * 0.75, 2)}/s)`;
const isLogActive = pState.logisticsEnabled !== false;
const ratePart = isLogActive ? `${fmtNum(count * 0.75, 2)}/s` : 'Paused';
const logText = `${count} fleets (${ratePart})`;
if (elLogistics.textContent !== logText) {
elLogistics.textContent = logText;
}
}
// Policy Button Toggles DOM State
const btnToggleLog = document.getElementById(`btn-toggle-logistics-${p.id}`);
if (btnToggleLog) {
const isLogActive = pState.logisticsEnabled !== false;
btnToggleLog.className = `btn-policy-toggle ${isLogActive ? 'active' : 'inactive'} btn-toggle-logistics`;
const textSpan = document.getElementById(`btn-toggle-logistics-text-${p.id}`);
if (textSpan) {
const tText = `Wholesale Fleet: ${isLogActive ? 'ON' : 'OFF'}`;
if (textSpan.textContent !== tText) textSpan.textContent = tText;
}
const iconEl = btnToggleLog.querySelector('iconify-icon');
if (iconEl) {
iconEl.setAttribute('icon', isLogActive ? 'lucide:truck' : 'lucide:truck-x');
}
}
const btnToggleDown = document.getElementById(`btn-toggle-downstream-${p.id}`);
if (btnToggleDown) {
const isDownActive = pState.supplyDownstreamEnabled !== false;
btnToggleDown.className = `btn-policy-toggle ${isDownActive ? 'active' : 'inactive'} btn-toggle-downstream`;
const textSpan = document.getElementById(`btn-toggle-downstream-text-${p.id}`);
if (textSpan) {
const tText = `Downstream Supply: ${isDownActive ? 'ON' : 'OFF'}`;
if (textSpan.textContent !== tText) textSpan.textContent = tText;
}
const iconEl = btnToggleDown.querySelector('iconify-icon');
if (iconEl) {
iconEl.setAttribute('icon', isDownActive ? 'lucide:git-pull-request' : 'lucide:git-pull-request-closed');
}
}
const elProgress = document.getElementById(`progress-${p.id}`);
if (elProgress) {
const progressPct = Math.min(100, Math.floor((pState.cycleProgress || 0) * 100));

View file

@ -39,12 +39,17 @@ export function getProductOutputRates(p, pState, state) {
// Logistics revenue rate: items sold passively per second * price
const logisticsUnits = pState.logistics || 0;
const isLogisticsActive = pState.logisticsEnabled !== false;
let revRate = 0;
if (isLogisticsActive && logisticsUnits > 0) {
const logisticsPriceMult = state.upgrades?.global_supply_chain ? 1.5 : 1.0;
const salePrice = p.basePrice * logisticsPriceMult;
const maxSellCapacity = logisticsUnits * 0.75;
// If stockpiled, sells at max fleet capacity; otherwise bottlenecked by production velocity
const effectiveSellRate = Math.min(maxSellCapacity, pState.stock > 0 ? maxSellCapacity : prodRate);
const revRate = effectiveSellRate * salePrice;
revRate = effectiveSellRate * salePrice;
}
return { prodRate, revRate, isBottlenecked, milestoneMult };
}
@ -63,6 +68,9 @@ export function computeProductNetFlow(product, state) {
// Calculate how much active downstream factories consume per second
let consumedRate = 0;
const isSupplyDownstreamActive = pState.supplyDownstreamEnabled !== false;
if (isSupplyDownstreamActive) {
for (const otherP of PRODUCTS) {
if (!otherP.inputs) continue;
const inpMatch = otherP.inputs.find(inp => inp.id === product.id);
@ -75,11 +83,17 @@ export function computeProductNetFlow(product, state) {
}
}
}
}
const logisticsUnits = pState.logistics || 0;
const isLogisticsActive = pState.logisticsEnabled !== false;
let soldRate = 0;
if (isLogisticsActive && logisticsUnits > 0) {
const maxSellCapacity = logisticsUnits * 0.75;
const availableForSale = Math.max(0, effectiveProd - consumedRate);
const soldRate = Math.min(maxSellCapacity, pState.stock > 0 ? maxSellCapacity : availableForSale);
soldRate = Math.min(maxSellCapacity, pState.stock > 0 ? maxSellCapacity : availableForSale);
}
const netRate = effectiveProd - consumedRate - soldRate;

View file

@ -64,6 +64,14 @@ export function mountProductionFloor(container, state, onAction, engine) {
container.querySelectorAll('.btn-expand-logistics').forEach(btn => {
btn.onclick = () => onAction({ type: 'BUY_LOGISTICS', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-toggle-logistics').forEach(btn => {
btn.onclick = () => onAction({ type: 'TOGGLE_LOGISTICS', productId: btn.dataset.product });
});
container.querySelectorAll('.btn-toggle-downstream').forEach(btn => {
btn.onclick = () => onAction({ type: 'TOGGLE_DOWNSTREAM_SUPPLY', productId: btn.dataset.product });
});
}
/**